From 65387c5dcded648a940e64bc22abddc901a3720d Mon Sep 17 00:00:00 2001 From: m4xx1m Date: Thu, 12 Nov 2026 17:30:44 +0300 Subject: [PATCH] feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI) --- .gitea/workflows/build.yaml | 107 +++ .gitignore | 14 + .vscode/launch.json | 19 - README.md | 25 + app.go | 296 ++++++++ build/appicon.png | Bin 0 -> 7012 bytes build/windows/app.manifest | 22 + build/windows/icon.ico | Bin 0 -> 9803 bytes ffprobe.go | 36 - frontend/index.html | 12 + frontend/package-lock.json | 1173 ++++++++++++++++++++++++++++ frontend/package.json | 15 + frontend/src/i18n.ts | 100 +++ frontend/src/main.ts | 1224 ++++++++++++++++++++++++++++++ frontend/src/style.css | 684 +++++++++++++++++ frontend/src/timecode.test.ts | 31 + frontend/src/timecode.ts | 94 +++ frontend/src/wails.d.ts | 132 ++++ frontend/tsconfig.json | 19 + frontend/vite.config.ts | 10 + go.mod | 34 +- go.sum | 81 ++ install.ps1 | 39 - installer/installer.nsi | 138 ++++ internal/config/config.go | 84 ++ internal/config/config_test.go | 42 + internal/convert/command.go | 367 +++++++++ internal/convert/command_test.go | 200 +++++ internal/convert/progress.go | 109 +++ internal/convert/runner.go | 168 ++++ internal/media/bin.go | 84 ++ internal/media/exec_other.go | 9 + internal/media/exec_windows.go | 19 + internal/media/probe.go | 220 ++++++ internal/media/probe_test.go | 96 +++ internal/preview/server.go | 300 ++++++++ internal/preview/server_test.go | 87 +++ main.go | 223 ++---- main_test.go | 18 + wails.json | 19 + 40 files changed, 6084 insertions(+), 266 deletions(-) create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore delete mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 app.go create mode 100644 build/appicon.png create mode 100644 build/windows/app.manifest create mode 100644 build/windows/icon.ico delete mode 100644 ffprobe.go create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/timecode.test.ts create mode 100644 frontend/src/timecode.ts create mode 100644 frontend/src/wails.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 go.sum delete mode 100644 install.ps1 create mode 100644 installer/installer.nsi create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/convert/command.go create mode 100644 internal/convert/command_test.go create mode 100644 internal/convert/progress.go create mode 100644 internal/convert/runner.go create mode 100644 internal/media/bin.go create mode 100644 internal/media/exec_other.go create mode 100644 internal/media/exec_windows.go create mode 100644 internal/media/probe.go create mode 100644 internal/media/probe_test.go create mode 100644 internal/preview/server.go create mode 100644 internal/preview/server_test.go create mode 100644 main_test.go create mode 100644 wails.json diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..7e9b90f --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,107 @@ +name: Build Windows Installer + +on: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install NSIS and build tools + run: | + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y nsis wget curl tar unzip zip ca-certificates + else + apt-get update + apt-get install -y nsis wget curl tar unzip zip ca-certificates + fi + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build & test frontend + working-directory: frontend + run: | + npm install + npm test + npm run build + + - name: Run Go unit tests + run: go test ./... + + - name: Determine version + id: ver + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + VER="${GITHUB_REF_NAME#v}" + else + VER="2.0.0-ci.${GITHUB_RUN_NUMBER}" + fi + echo "version=${VER}" >> "$GITHUB_OUTPUT" + + - name: Embed Windows icon and manifest (.syso) + run: | + go run github.com/akavel/rsrc@v0.10.2 \ + -arch amd64 \ + -ico build/windows/icon.ico \ + -manifest build/windows/app.manifest \ + -o rsrc_windows_amd64.syso + + - name: Cross-compile Windows binary (convert.exe) + env: + CGO_ENABLED: "0" + GOOS: windows + GOARCH: amd64 + run: | + mkdir -p dist/bin + go build -tags desktop,production \ + -ldflags="-H windowsgui -s -w -X main.Version=${{ steps.ver.outputs.version }}" \ + -o dist/convert.exe . + + - name: Download static FFmpeg & FFprobe for Windows + run: | + wget -q -O ffmpeg.zip "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" + unzip -j -q ffmpeg.zip "*/bin/ffmpeg.exe" "*/bin/ffprobe.exe" -d dist/bin/ + rm -f ffmpeg.zip + + - name: Package Portable ZIP + run: | + cd dist + zip -r "ConvertGUI-Portable-${{ steps.ver.outputs.version }}-win64.zip" convert.exe bin/ + + - name: Build Windows NSIS Installer + run: | + makensis -DVERSION="${{ steps.ver.outputs.version }}" installer/installer.nsi + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v3 + with: + name: ConvertGUI-Windows-${{ steps.ver.outputs.version }} + path: | + dist/ConvertGUI-Setup-${{ steps.ver.outputs.version }}.exe + dist/ConvertGUI-Portable-${{ steps.ver.outputs.version }}-win64.zip + + - name: Publish Gitea Release + if: startsWith(github.ref, 'refs/tags/v') + uses: akkuman/gitea-release-action@v1 + with: + files: | + dist/ConvertGUI-Setup-${{ steps.ver.outputs.version }}.exe + dist/ConvertGUI-Portable-${{ steps.ver.outputs.version }}-win64.zip + name: Release ${{ github.ref_name }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00a349c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binaries & build outputs +/dist/ +/build/bin/ +/frontend/dist/ +/frontend/node_modules/ +*.exe +*.syso +*.log +.DS_Store +Thumbs.db + +# IDE +.vscode/ +!.vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 33d3e73..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "debug", - "type": "go", - "request": "launch", - "mode": "debug", - "program": "main.go", - "cwd": "${cwd}", - "console": "integratedTerminal", - // "args": ["--debug", "-i", "C:\\Users\\m4xx1m\\Videos\\NVIDIA\\Desktop\\Desktop 2025.07.09 - 13.10.33.02.mp4"] - "args": ["-i", "E:\\records\\Derail Valley\\Derail Valley 2025.08.14 - 02.18.46.02.mp4"] - }, - ] -} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..afbb89b --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# ConvertGUI + +GUI video converter/trimmer powered by Go, Wails v2 (WebView2), and FFmpeg. + +## Features +- Video preview with HTTP byte-range seeking + automatic MP4 proxy fallback (MKV/AVI/HEVC) +- Interactive dual-handle timeline trim (`-ss`, `-to`), filmstrip thumbnails, loop-selection preview, `I`/`O` hotkeys +- Optional spatial crop overlay (`crop=w:h:x:y`, even-dimension clamped) +- Multi-audio stream handling (`amix=inputs=N:normalize=0`, single-track selection, or `-an` mute) +- Speed multiplier (`0.25x`–`8x`) with `setpts` + chained `atempo` +- Codecs: `libx264`, `h264_nvenc`, `libx265`, `hevc_nvenc`; HW decode (`none`, `nvdec`, `d3d11va`, `auto`); CRF/CQ, preset, tune, FPS +- Windows Explorer context menu (`-i "%1"`), drag-and-drop, NSIS installer + +## Dev / Build +```powershell +cd frontend && npm install && npm run build +cd .. && go test ./... +$env:CGO_ENABLED="0"; $env:GOOS="windows"; $env:GOARCH="amd64" +go build -tags desktop,production -ldflags="-H windowsgui -s -w" -o dist/convert.exe . +``` + +## CI +- Workflow: `.gitea/workflows/build.yaml` (`ubuntu-latest`) +- Builds `ConvertGUI-Setup-.exe` (NSIS + bundled static `ffmpeg.exe`/`ffprobe.exe`) and `ConvertGUI-Portable--win64.zip` +- Publishes release assets on `v*` tags via `akkuman/gitea-release-action@v1` diff --git a/app.go b/app.go new file mode 100644 index 0000000..6d722d8 --- /dev/null +++ b/app.go @@ -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() +} diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..091709cafae9b70351e8fbee1bcf9af9808ea48c GIT binary patch literal 7012 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 literal 0 HcmV?d00001 diff --git a/build/windows/app.manifest b/build/windows/app.manifest new file mode 100644 index 0000000..dad079a --- /dev/null +++ b/build/windows/app.manifest @@ -0,0 +1,22 @@ + + + + ConvertGUI Video Converter + + + + + + + + true/pm + PerMonitorV2 + + + + + + + + + diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..86fc0f71d7e9d15d212d60c0feb83700445365c9 GIT binary patch literal 9803 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 literal 0 HcmV?d00001 diff --git a/ffprobe.go b/ffprobe.go deleted file mode 100644 index 1a5ce0c..0000000 --- a/ffprobe.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "encoding/json" - "os/exec" -) - -type ffprobeOutput struct { - Streams []struct { - CodecType string `json:"codec_type"` - } `json:"streams"` -} - -func ffprobe(inputFile string) (*ffprobeOutput, error) { - var err error - var out ffprobeOutput - - args := []string{ - "-v", "quiet", - "-print_format", "json", - "-show_streams", - inputFile, - } - proc := exec.Command("ffprobe", args...) - stdout, err := proc.Output() - if err != nil { - return nil, err - } - // fmt.Println("read", string(stdout)) - - err = json.Unmarshal(stdout, &out) - if err != nil { - return nil, err - } - return &out, nil -} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a889197 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ConvertGUI + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a133986 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1173 @@ +{ + "name": "convert-gui-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "convert-gui-frontend", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.68.2.tgz", + "integrity": "sha512-ItKrNMkEL3hy85kLBXwB0+M6CWWVI8vr7RV+Eb7oDlQIMXLaiEhGfdaj5jAUadYn2v3nwzAhIRl/nLyjAqV9vQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.68.2.tgz", + "integrity": "sha512-dCkT8BMPLo6X6GCFVtuzrnr981PpJSeK4ZO/d/mZdJ/ao61Qa1Usd/OHIQ69MQgw4qQclR/X6uRRUONiWwD8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.68.2.tgz", + "integrity": "sha512-p2jkKiscqQOGmyL6qu+KvJctvBc3SQTd+Xi2lQYn5QvFLEWz+QI8Z9iI+xcIjez6vKRsSnWP56Y3Ep7g3Stiug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.68.2.tgz", + "integrity": "sha512-aVIfOteo5zoX8aL742H/S3QsyN6aokNYjdzrLnQBKxgRVoh4cibTErpi61RzSmrv2hBzDB1i751hwwdc8QK0vQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.68.2.tgz", + "integrity": "sha512-+Wuf4TFTHiE/WnjKgH1SkNf/9D5ddoed1UR0p3jxjNAGemdeeQRL+r1bJ1LlZyha0VKDBYLB2U/a9a9KlSw4Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.68.2.tgz", + "integrity": "sha512-feKK2dc13j/6D9mAp7XKhBM/VUqn0N/xRgP6rT7/mdRVVJUaeZ4lsJ9hN90aTi0k3OD+mkjlcN01vQ96Ac9VzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.68.2.tgz", + "integrity": "sha512-s1oGXPGX1PNqdi9TLWD+RwtPEYf/BOTbCcq4anJBqNbzyjucti86ldYH37eKvsyTP7Bl5/Hf8XmbIxCz/iafTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.68.2.tgz", + "integrity": "sha512-ksR3ZmzHrm6HOUP8UlDBy5hNTcG9cEIa3EyGnxn5BjorLRjBh4ucxhwjwwVEm0dqcIN4Egc/XjDUiPQwN1ic9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.68.2.tgz", + "integrity": "sha512-STZVVBM35wULVsQah0xKfCBQMPpHliOfjnRaUNT35jrbpfapUrD8ix6Yhl7tLHW0xQSWotXSSghjUi/DclwRSw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.68.2.tgz", + "integrity": "sha512-Fm/GPC2R0zlJYQIp9aY9L1lO2z+P+UrcvXG6fhubfo/BbMOLZlLXshnbECOTqr3pxfZFb6nNn0QgDETDtrX7TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.68.2.tgz", + "integrity": "sha512-VoqlckWcwNF3WqsUkLZg91zYFgBvrC37Lc5e8AU+BKdCUFDMPoN0Rsin/LvF+rBVF527gWo5KT0oHkaAZaL1GA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.68.2.tgz", + "integrity": "sha512-Zr8X/SUf7Jjwxt99BqPDF/Wq+sz4oUBIP6HBaFQWwb8wO1nV6LBIpD6jB9cMybLho93Z+B8rrN0uXEL6oNOUJQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.68.2.tgz", + "integrity": "sha512-2EhrYEJNXIyhSZESwm/kCke1tiH7QAPOU3v3MsLM9J+Nfn2QCDe/pGROUAvYFK0BL2MEbzNgfrqrbnE5CFXkLw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.68.2.tgz", + "integrity": "sha512-IC0dYuEQknw0m2FYBNBKlbrgBJBH5OMRiaRfCc4CFSpYnmDnqTpNSv+TgX+loezGTdRjBNZuYZi3t+Oc5NxNYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.68.2.tgz", + "integrity": "sha512-Zlzm6fGuDvtAqMEl071/g17vxq6u2Y2v/rON/gDY4NvzWLJ41BGAlwhF3QuAzwq05zIPo4LYzdqwhx3oisYCUw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.68.2.tgz", + "integrity": "sha512-KhEpYRtHZ3Kj4shavjXeH+K1bQlA6UbP6MJ45rMPF/11JlZb1SRNLeuWCdigUEoMMaFO4+LwwcS+ew1SoLJu/g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.68.2.tgz", + "integrity": "sha512-LPn9qvPsM/Ofd6Ekq3im6nB8Tk1ruJhTd3x/2bJkTR8m/XTsfxA+EU0RJ23WJqgJg123kitHAqbEK7B4cL1uPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.68.2.tgz", + "integrity": "sha512-Q1+E+pzSP+FiXuIZtxKR+pK7l2KQ9gsM+VaFPv1LR277kp7l61crG46WGLKLLay3Js5cvyU9Keqdzxa1EcFO+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.68.2.tgz", + "integrity": "sha512-3Btp4Lca/WzsmbaKu9Lh9d/XwyRDIegd/5khRBusHGw/Ll6SoF5Shn63OrjPdkLcpNi2PGDApXzNmxriRxGYjA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.68.2.tgz", + "integrity": "sha512-5SKVaUiVs18+DGBnpg0mORGxgq/0udczngGxV/vf2nvK1djqpOKIxY/+8rBWxAbWGRE4RnnwxWabDwJN3pCwIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.68.2.tgz", + "integrity": "sha512-KBpm48/lESsHgWd+dRlcy18APBYhXU4Jb6VlJoqbpWNwfpztFXwP22qVv9aHd3k1quSrmLeJwYLc7rpQ+BFZrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.68.2.tgz", + "integrity": "sha512-o0D0bfRW8QGA1Ph6OzqVHMq5LRu1xkr1iM7qQIXUSFuR60ddXeDhN9UR4bUBPLYsrT46sZwwgQa51TWtBV3r5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.68.2.tgz", + "integrity": "sha512-r17kcu5kmd6WQwokYmfBnfwM/icureCWCPjpJVALcrSv5Lfy6Ggk9azxXor6hILhCHbc/4IfEiv+L5RXaOHinA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.68.2.tgz", + "integrity": "sha512-yUtOqq7ZrQoZ3WiHSR6QGTdOl4YgMHqXIYSBYU6siIcC0YF0pEwx7aGsPTm2mRwrmW6SMJkxzEdFm6wqXpmbNw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.68.2.tgz", + "integrity": "sha512-5IYoWxQGo5WECbJbImIf2w33uCHJNLv4o/FQtSyeYDed7AC6zx84NsEcGYccoI2geDEYkrxUXcsYK/6EhwVSIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.68.2.tgz", + "integrity": "sha512-ee+c14ggUiM7lg4PtCjSBYU9O9dHp4FFespRnd5yj7SilUBJCL3x3aHlB+sIqbGjcPo3pEyODASnfKJuZ26YBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.68.2.tgz", + "integrity": "sha512-xzt45rKOxzEuqjTdkGTIEaePcq9xRELlFI1uoazlKap8T6bENJkONSCfhK51P6XFTixa4rosPcDHihSdduXndQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.68.2", + "@rollup/rollup-android-arm64": "4.68.2", + "@rollup/rollup-android-x64": "4.68.2", + "@rollup/rollup-darwin-arm64": "4.68.2", + "@rollup/rollup-darwin-x64": "4.68.2", + "@rollup/rollup-freebsd-arm64": "4.68.2", + "@rollup/rollup-freebsd-x64": "4.68.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.68.2", + "@rollup/rollup-linux-arm-musleabihf": "4.68.2", + "@rollup/rollup-linux-arm64-gnu": "4.68.2", + "@rollup/rollup-linux-arm64-musl": "4.68.2", + "@rollup/rollup-linux-loong64-gnu": "4.68.2", + "@rollup/rollup-linux-loong64-musl": "4.68.2", + "@rollup/rollup-linux-ppc64-gnu": "4.68.2", + "@rollup/rollup-linux-ppc64-musl": "4.68.2", + "@rollup/rollup-linux-riscv64-gnu": "4.68.2", + "@rollup/rollup-linux-riscv64-musl": "4.68.2", + "@rollup/rollup-linux-s390x-gnu": "4.68.2", + "@rollup/rollup-linux-x64-gnu": "4.68.2", + "@rollup/rollup-linux-x64-musl": "4.68.2", + "@rollup/rollup-openbsd-x64": "4.68.2", + "@rollup/rollup-openharmony-arm64": "4.68.2", + "@rollup/rollup-win32-arm64-msvc": "4.68.2", + "@rollup/rollup-win32-ia32-msvc": "4.68.2", + "@rollup/rollup-win32-x64-gnu": "4.68.2", + "@rollup/rollup-win32-x64-msvc": "4.68.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.6.tgz", + "integrity": "sha512-RtsraAwEG68idOA6iUOoIpOlcSKROnf815aIq9qHTSrDNpLoBrBa12suyfcEd8qn0xwOVQI1Yhm1GbmTjF600A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bf32bfd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "convert-gui-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "test": "node --experimental-strip-types --test src/timecode.test.ts" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..43e7a07 --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,100 @@ +export type Lang = 'ru' | 'en'; + +export const dict = { + ru: { + openVideo: 'Открыть видео', + dropTitle: 'Перетащите видео сюда', + dropHint: 'MP4, MKV, MOV, AVI, WebM — или нажмите «Открыть видео»', + ffmpegOk: 'FFmpeg готов', + ffmpegMissing: 'FFmpeg не найден (указать)', + setStart: 'Начало [I]', + setEnd: 'Конец [O]', + loopTrim: 'Повтор фрагмента', + previewMix: 'Микс дорожек в превью', + proxyLoading: 'Подготовка совместимого превью...', + trimStart: 'Начало (ss)', + trimEnd: 'Конец (to)', + clipDur: 'Длительность', + outDur: 'Итог (со скор.)', + resetTrim: 'Сбросить', + sectionVideo: 'Видео и качество', + encoder: 'Кодек', + hwDecode: 'Аппар. декодинг', + crf: 'Качество (CRF / CQ)', + preset: 'Пресет скорости', + tune: 'Тонкая настройка', + fps: 'FPS', + fpsOriginal: 'Оригинал', + sectionSpeed: 'Скорость воспроизведения', + sectionAudio: 'Аудиодорожки', + audioMerge: 'Объединить (amix)', + audioTrack: 'Одна дорожка', + audioMute: 'Без звука (-an)', + noAudio: 'В видео нет аудиодорожек', + sectionCrop: 'Кадрирование (Crop)', + cropEnable: 'Включить обрезку кадра', + sectionOutput: 'Выходной файл', + browse: 'Обзор...', + showCli: 'Команда FFmpeg', + copy: 'Копировать', + copied: 'Скопировано', + convertBtn: 'Конвертировать', + cancelBtn: 'Отмена', + doneTitle: 'Готово!', + openFolder: 'Открыть папку', + playOutput: 'Открыть файл', + crfHintBest: 'Почти без потерь', + crfHintBal: 'Оптимальный баланс', + crfHintComp: 'Сильное сжатие', + crfHintSmall: 'Минимальный размер', + eta: 'Осталось', + }, + en: { + openVideo: 'Open Video', + dropTitle: 'Drop a video file here', + dropHint: 'MP4, MKV, MOV, AVI, WebM — or click "Open Video"', + ffmpegOk: 'FFmpeg ready', + ffmpegMissing: 'FFmpeg missing (locate)', + setStart: 'Set In [I]', + setEnd: 'Set Out [O]', + loopTrim: 'Loop Trim', + previewMix: 'Mix Audio in Preview', + proxyLoading: 'Preparing compatible preview...', + trimStart: 'Start (ss)', + trimEnd: 'End (to)', + clipDur: 'Selection', + outDur: 'Output (w/ speed)', + resetTrim: 'Reset', + sectionVideo: 'Video & Quality', + encoder: 'Encoder', + hwDecode: 'HW Decode', + crf: 'Quality (CRF / CQ)', + preset: 'Preset', + tune: 'Tune', + fps: 'FPS', + fpsOriginal: 'Original', + sectionSpeed: 'Playback Speed', + sectionAudio: 'Audio Streams', + audioMerge: 'Merge Tracks (amix)', + audioTrack: 'Single Track', + audioMute: 'Mute (-an)', + noAudio: 'No audio streams in source', + sectionCrop: 'Frame Crop', + cropEnable: 'Enable spatial crop', + sectionOutput: 'Output File', + browse: 'Browse...', + showCli: 'FFmpeg CLI', + copy: 'Copy', + copied: 'Copied', + convertBtn: 'Convert Video', + cancelBtn: 'Cancel', + doneTitle: 'Conversion Complete!', + openFolder: 'Show in Folder', + playOutput: 'Open File', + crfHintBest: 'Near lossless', + crfHintBal: 'Sweet spot', + crfHintComp: 'Compact size', + crfHintSmall: 'Heavy compression', + eta: 'ETA', + }, +} as const; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..6ba7656 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,1224 @@ +import './style.css'; +import { dict, type Lang } from './i18n.ts'; +import { clampTrimRange, formatBytes, formatTimecode, parseTimecode } from './timecode.ts'; +import type { + AppInfo, + ConvertRequest, + LoadedVideo, + ProgressEvent, +} from './wails.d.ts'; + +interface State { + lang: Lang; + appInfo: AppInfo | null; + loaded: LoadedVideo | null; + trimStart: number; + trimEnd: number; + loopTrim: boolean; + previewMixAudio: boolean; + usingProxy: boolean; + encoder: string; + hwDecode: string; + crf: number; + preset: string; + tune: string; + fps: number; + speed: number; + audioMode: 'merge' | 'track' | 'mute'; + audioTrack: number; + selectedTracks: number[]; + cropEnabled: boolean; + cropX: number; + cropY: number; + cropW: number; + cropH: number; + outputPath: string; + converting: boolean; + progress: ProgressEvent | null; + donePath: string; + doneBytes: number; + errorMsg: string; + cliExpanded: boolean; + cliText: string; +} + +const state: State = { + lang: 'ru', + appInfo: null, + loaded: null, + trimStart: 0, + trimEnd: 0, + loopTrim: true, + previewMixAudio: false, + usingProxy: false, + encoder: 'libx264', + hwDecode: 'none', + crf: 20, + preset: 'slow', + tune: 'film', + fps: 60, + speed: 1, + audioMode: 'merge', + audioTrack: 0, + selectedTracks: [], + cropEnabled: false, + cropX: 0, + cropY: 0, + cropW: 1280, + cropH: 720, + outputPath: '', + converting: false, + progress: null, + donePath: '', + doneBytes: 0, + errorMsg: '', + cliExpanded: false, + cliText: '', +}; + +const appApi = () => window.go?.main?.App; +const t = () => dict[state.lang]; + +function el(id: string): HTMLElementTagNameMap[K] { + return document.getElementById(id) as HTMLElementTagNameMap[K]; +} + +function renderShell() { + const tr = t(); + const root = document.getElementById('app')!; + root.innerHTML = ` +
+
+
▶
+ ConvertGUI + v1.0 +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
🎬
+

${tr.dropTitle}

+
${tr.dropHint}
+
+
+ +
+
+ + + + 00:00:00.000 / 00:00:00.000 +
+
+ + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ +
00:00.000
+
+
+ +
00:00.000
+
+ +
+
+
+ + +
+ `; + + bindEvents(); + syncControlsFromState(); + updateFFmpegBadge(); +} + +function updateFFmpegBadge() { + const btn = el<'button'>('ffmpegStatusBtn'); + const tr = t(); + const bins = state.appInfo?.binaries; + if (bins && bins.ffmpeg && bins.ffprobe) { + btn.className = 'status-pill ok'; + btn.textContent = `✓ ${tr.ffmpegOk}`; + btn.title = `${bins.ffmpeg} (${bins.source})`; + } else { + btn.className = 'status-pill warn'; + btn.textContent = `⚠ ${tr.ffmpegMissing}`; + btn.title = 'Click to locate ffmpeg.exe'; + } +} + +function crfQualityHint(crf: number): string { + const tr = t(); + if (crf <= 18) return tr.crfHintBest; + if (crf <= 23) return tr.crfHintBal; + if (crf <= 28) return tr.crfHintComp; + return tr.crfHintSmall; +} + +function syncControlsFromState() { + el<'select'>('encoderSelect').value = state.encoder; + el<'select'>('hwDecodeSelect').value = state.hwDecode; + el<'input'>('crfRange').value = String(state.crf); + el<'span'>('crfLabel').textContent = `${state.crf} · ${crfQualityHint(state.crf)}`; + el<'select'>('presetSelect').value = state.preset; + el<'select'>('tuneSelect').value = state.tune; + el<'select'>('tuneSelect').disabled = state.encoder !== 'libx264'; + el<'select'>('fpsSelect').value = String(state.fps); + el<'input'>('speedRange').value = String(state.speed); + el<'span'>('speedVal').textContent = `${state.speed}x`; + el<'input'>('outputInput').value = state.outputPath; + + document.querySelectorAll('#speedPills button').forEach((b) => { + b.classList.toggle('pill-active', Number(b.dataset.speed) === state.speed); + }); + + const video = el<'video'>('videoEl'); + if (video) { + video.playbackRate = Math.max(0.25, Math.min(8, state.speed)); + } + + el<'input'>('cropCheck').checked = state.cropEnabled; + el<'div'>('cropControls').classList.toggle('hidden', !state.cropEnabled); + el<'div'>('cropOverlay').classList.toggle('hidden', !state.cropEnabled || !state.loaded); + el<'input'>('cropX').value = String(state.cropX); + el<'input'>('cropY').value = String(state.cropY); + el<'input'>('cropW').value = String(state.cropW); + el<'input'>('cropH').value = String(state.cropH); + updateCropBoxVisual(); + + renderAudioSection(); + updateTimelineVisuals(); + void refreshCLI(); +} + +function renderAudioSection() { + const tr = t(); + const container = el<'div'>('audioTracksContainer'); + const tracks = state.loaded?.info.audioStreams ?? []; + + document.querySelectorAll('#audioModeBtns button').forEach((b) => { + const mode = b.dataset.amode; + b.classList.toggle('pill-active', mode === state.audioMode); + if (mode === 'merge') { + b.disabled = tracks.length < 2; + } else { + b.disabled = tracks.length === 0 && mode !== 'mute'; + } + }); + + if (!state.loaded) { + container.innerHTML = ''; + return; + } + + if (tracks.length === 0) { + container.innerHTML = `
${tr.noAudio}
`; + return; + } + + if (state.audioMode === 'mute') { + container.innerHTML = ''; + return; + } + + container.innerHTML = tracks + .map((s) => { + const label = [ + `#${s.audioOrder + 1}`, + s.codec.toUpperCase(), + s.channelLayout || `${s.channels}ch`, + s.title || s.language || '', + ] + .filter(Boolean) + .join(' · '); + + if (state.audioMode === 'merge') { + const checked = + state.selectedTracks.length === 0 || state.selectedTracks.includes(s.audioOrder); + return ``; + } + const checked = state.audioTrack === s.audioOrder; + return ``; + }) + .join(''); + + container.querySelectorAll('[data-atrack-check]').forEach((cb) => { + cb.addEventListener('change', () => { + const next: number[] = []; + container.querySelectorAll('[data-atrack-check]').forEach((box) => { + if (box.checked) next.push(Number(box.dataset.atrackCheck)); + }); + state.selectedTracks = next; + void refreshCLI(); + }); + }); + + container.querySelectorAll('[data-atrack-radio]').forEach((rb) => { + rb.addEventListener('change', () => { + state.audioTrack = Number(rb.dataset.atrackRadio); + void refreshCLI(); + }); + }); +} + +function updateTimelineVisuals() { + const dur = state.loaded?.info.duration ?? 0; + const startPct = dur > 0 ? (state.trimStart / dur) * 100 : 0; + const endPct = dur > 0 ? (state.trimEnd / dur) * 100 : 100; + + el<'div'>('dimLeft').style.width = `${startPct}%`; + el<'div'>('dimRight').style.width = `${100 - endPct}%`; + const sel = el<'div'>('trimSelection'); + sel.style.left = `${startPct}%`; + sel.style.width = `${Math.max(0, endPct - startPct)}%`; + + el<'div'>('handleStart').style.left = `${startPct}%`; + el<'div'>('handleEnd').style.left = `${endPct}%`; + + const selDur = Math.max(0, state.trimEnd - state.trimStart); + const outDur = selDur / (state.speed || 1); + el<'div'>('clipDurChip').textContent = formatTimecode(selDur, true); + el<'div'>('outDurChip').textContent = formatTimecode(outDur, true); + + const startInp = el<'input'>('trimStartInput'); + const endInp = el<'input'>('trimEndInput'); + if (document.activeElement !== startInp) { + startInp.value = formatTimecode(state.trimStart, true); + } + if (document.activeElement !== endInp) { + endInp.value = formatTimecode(state.trimEnd, true); + } +} + +function updatePlayhead(currentTime: number) { + const dur = state.loaded?.info.duration ?? 0; + const pct = dur > 0 ? Math.max(0, Math.min(100, (currentTime / dur) * 100)) : 0; + el<'div'>('playhead').style.left = `${pct}%`; + el<'span'>('timeReadout').textContent = `${formatTimecode(currentTime, true)} / ${formatTimecode(dur, true)}`; +} + +function buildRequest(): ConvertRequest { + return { + inputPath: state.loaded?.info.path ?? '', + outputPath: state.outputPath, + trimStart: state.trimStart, + trimEnd: state.trimEnd, + encoder: state.encoder, + hwDecode: state.hwDecode, + preset: state.preset, + crf: state.crf, + tune: state.tune, + fps: state.fps, + speed: state.speed, + audioMode: state.audioMode, + audioTrack: state.audioTrack, + selectedTracks: state.selectedTracks, + crop: { + enabled: state.cropEnabled, + x: state.cropX, + y: state.cropY, + w: state.cropW, + h: state.cropH, + }, + fastStart: true, + overwrite: true, + }; +} + +async function refreshCLI() { + const api = appApi(); + if (!api || !state.loaded) { + el<'pre'>('cliBox').textContent = ''; + return; + } + try { + const cmd = await api.PreviewCommand(buildRequest()); + state.cliText = cmd; + el<'pre'>('cliBox').textContent = cmd; + } catch { + // ignore validation transient + } +} + +async function loadVideoFile(path: string) { + const api = appApi(); + if (!api || !path) return; + state.errorMsg = ''; + state.donePath = ''; + el<'div'>('errorBanner').classList.add('hidden'); + el<'div'>('doneBanner').classList.add('hidden'); + + try { + const res = await api.LoadVideo(path); + state.loaded = res; + state.usingProxy = false; + state.previewMixAudio = false; + state.trimStart = 0; + state.trimEnd = res.info.duration; + state.outputPath = res.defaultOutputPath; + + // Default crop to full frame + state.cropEnabled = false; + state.cropX = 0; + state.cropY = 0; + state.cropW = res.info.width - (res.info.width % 2); + state.cropH = res.info.height - (res.info.height % 2); + + // Default audio mode based on track count + if (res.info.audioStreams.length === 0) { + state.audioMode = 'mute'; + state.selectedTracks = []; + } else if (res.info.audioStreams.length >= 2) { + state.audioMode = 'merge'; + state.selectedTracks = res.info.audioStreams.map((a) => a.audioOrder); + } else { + state.audioMode = 'track'; + state.audioTrack = 0; + state.selectedTracks = [0]; + } + + // Update UI header & enable controls + el<'div'>('filePill').classList.remove('hidden'); + el<'strong'>('fileName').textContent = res.info.fileName; + el<'span'>('fileMeta').textContent = `${res.info.width}×${res.info.height} · ${res.info.fps ? res.info.fps.toFixed(0) + 'fps' : '?'} · ${res.info.videoCodec.toUpperCase()} · ${formatBytes(res.info.sizeBytes)}`; + + el<'div'>('dropOverlay').classList.add('hidden'); + el<'div'>('videoStage').classList.remove('hidden'); + el<'button'>('playPauseBtn').disabled = false; + el<'button'>('stepBackBtn').disabled = false; + el<'button'>('stepFwdBtn').disabled = false; + el<'button'>('setStartBtn').disabled = false; + el<'button'>('setEndBtn').disabled = false; + el<'button'>('resetTrimBtn').disabled = false; + el<'input'>('trimStartInput').disabled = false; + el<'input'>('trimEndInput').disabled = false; + el<'button'>('convertBtn').disabled = false; + + const mixBtn = el<'button'>('previewMixBtn'); + mixBtn.classList.toggle('hidden', res.info.audioStreams.length < 2); + mixBtn.classList.remove('pill-active'); + + loadFilmstrip(res); + setPlayerSource(res, false); + syncControlsFromState(); + updatePlayhead(0); + } catch (err) { + showError(String(err)); + } +} + +function setPlayerSource(res: LoadedVideo, forceProxy: boolean) { + const video = el<'video'>('videoEl'); + const banner = el<'div'>('proxyBanner'); + + let src = res.previewUrl; + let needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio; + if (needsProxy) { + src = state.previewMixAudio ? res.proxyMergedUrl : res.proxyUrl; + state.usingProxy = true; + banner.classList.remove('hidden'); + } else { + state.usingProxy = false; + banner.classList.add('hidden'); + } + + video.src = src; + video.playbackRate = state.speed; + video.load(); +} + +function loadFilmstrip(res: LoadedVideo) { + const strip = el<'div'>('filmstrip'); + const dur = Math.max(0.1, res.info.duration); + const count = 12; + let html = ''; + for (let i = 0; i < count; i++) { + const tSec = ((i + 0.5) / count) * dur; + html += ``; + } + strip.innerHTML = html; +} + +function updateCropBoxVisual() { + if (!state.loaded || !state.cropEnabled) return; + const info = state.loaded.info; + if (info.width <= 0 || info.height <= 0) return; + + const box = el<'div'>('cropBox'); + const left = (state.cropX / info.width) * 100; + const top = (state.cropY / info.height) * 100; + const width = (state.cropW / info.width) * 100; + const height = (state.cropH / info.height) * 100; + + box.style.left = `${left}%`; + box.style.top = `${top}%`; + box.style.width = `${width}%`; + box.style.height = `${height}%`; +} + +function showError(msg: string) { + state.errorMsg = msg; + const b = el<'div'>('errorBanner'); + b.textContent = msg; + b.classList.remove('hidden'); +} + +function bindEvents() { + const api = appApi(); + + el<'button'>('openBtn').addEventListener('click', async () => { + const path = await api?.OpenVideoDialog(); + if (path) await loadVideoFile(path); + }); + + el<'div'>('dropOverlay').addEventListener('click', async () => { + const path = await api?.OpenVideoDialog(); + if (path) await loadVideoFile(path); + }); + + el<'button'>('ffmpegStatusBtn').addEventListener('click', async () => { + const info = await api?.SelectFFmpegDialog(); + if (info) { + state.appInfo = info; + updateFFmpegBadge(); + } + }); + + el<'button'>('langBtn').addEventListener('click', async () => { + state.lang = state.lang === 'ru' ? 'en' : 'ru'; + if (state.appInfo) { + state.appInfo.config.language = state.lang; + await api?.SaveConfig(state.appInfo.config); + } + const prevLoaded = state.loaded; + renderShell(); + if (prevLoaded) { + state.loaded = prevLoaded; + el<'div'>('filePill').classList.remove('hidden'); + el<'strong'>('fileName').textContent = prevLoaded.info.fileName; + el<'span'>('fileMeta').textContent = `${prevLoaded.info.width}×${prevLoaded.info.height} · ${prevLoaded.info.fps ? prevLoaded.info.fps.toFixed(0) + 'fps' : '?'} · ${prevLoaded.info.videoCodec.toUpperCase()} · ${formatBytes(prevLoaded.info.sizeBytes)}`; + el<'div'>('dropOverlay').classList.add('hidden'); + el<'div'>('videoStage').classList.remove('hidden'); + el<'button'>('playPauseBtn').disabled = false; + el<'button'>('stepBackBtn').disabled = false; + el<'button'>('stepFwdBtn').disabled = false; + el<'button'>('setStartBtn').disabled = false; + el<'button'>('setEndBtn').disabled = false; + el<'button'>('resetTrimBtn').disabled = false; + el<'input'>('trimStartInput').disabled = false; + el<'input'>('trimEndInput').disabled = false; + el<'button'>('convertBtn').disabled = false; + el<'button'>('previewMixBtn').classList.toggle('hidden', prevLoaded.info.audioStreams.length < 2); + loadFilmstrip(prevLoaded); + setPlayerSource(prevLoaded, state.usingProxy); + syncControlsFromState(); + } + }); + + // Video element events + const video = el<'video'>('videoEl'); + video.addEventListener('canplay', () => { + el<'div'>('proxyBanner').classList.add('hidden'); + }); + video.addEventListener('error', () => { + if (state.loaded && !state.usingProxy) { + setPlayerSource(state.loaded, true); + } + }); + video.addEventListener('timeupdate', () => { + if (state.loopTrim && !video.paused && state.trimEnd > state.trimStart) { + if (video.currentTime >= state.trimEnd) { + video.currentTime = state.trimStart; + } + } + updatePlayhead(video.currentTime); + }); + + // Transport + const togglePlay = () => { + if (!state.loaded) return; + if (video.paused) { + if (state.loopTrim && (video.currentTime < state.trimStart || video.currentTime >= state.trimEnd)) { + video.currentTime = state.trimStart; + } + void video.play(); + } else { + video.pause(); + } + }; + el<'button'>('playPauseBtn').addEventListener('click', togglePlay); + el<'button'>('stepBackBtn').addEventListener('click', () => { + video.pause(); + video.currentTime = Math.max(0, video.currentTime - 0.1); + }); + el<'button'>('stepFwdBtn').addEventListener('click', () => { + video.pause(); + video.currentTime = Math.min(state.loaded?.info.duration ?? 0, video.currentTime + 0.1); + }); + el<'button'>('setStartBtn').addEventListener('click', () => { + if (!state.loaded) return; + const clamped = clampTrimRange(video.currentTime, state.trimEnd, state.loaded.info.duration); + state.trimStart = clamped.start; + state.trimEnd = clamped.end; + updateTimelineVisuals(); + void refreshCLI(); + }); + el<'button'>('setEndBtn').addEventListener('click', () => { + if (!state.loaded) return; + const clamped = clampTrimRange(state.trimStart, video.currentTime, state.loaded.info.duration); + state.trimStart = clamped.start; + state.trimEnd = clamped.end; + updateTimelineVisuals(); + void refreshCLI(); + }); + el<'button'>('loopBtn').addEventListener('click', () => { + state.loopTrim = !state.loopTrim; + el<'button'>('loopBtn').classList.toggle('pill-active', state.loopTrim); + }); + el<'button'>('previewMixBtn').addEventListener('click', () => { + if (!state.loaded) return; + state.previewMixAudio = !state.previewMixAudio; + el<'button'>('previewMixBtn').classList.toggle('pill-active', state.previewMixAudio); + const cur = video.currentTime; + setPlayerSource(state.loaded, state.previewMixAudio); + video.addEventListener( + 'loadedmetadata', + () => { + video.currentTime = cur; + }, + { once: true } + ); + }); + el<'button'>('mutePreviewBtn').addEventListener('click', () => { + video.muted = !video.muted; + el<'button'>('mutePreviewBtn').textContent = video.muted ? '🔇' : '🔊'; + }); + + // Timeline drag (handles + seek) + const track = el<'div'>('timelineTrack'); + let dragTarget: 'seek' | 'start' | 'end' | null = null; + + const clientXToTime = (clientX: number): number => { + const rect = track.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return ratio * (state.loaded?.info.duration ?? 0); + }; + + el<'div'>('handleStart').addEventListener('pointerdown', (e) => { + e.stopPropagation(); + dragTarget = 'start'; + }); + el<'div'>('handleEnd').addEventListener('pointerdown', (e) => { + e.stopPropagation(); + dragTarget = 'end'; + }); + track.addEventListener('pointerdown', (e) => { + if (!state.loaded) return; + dragTarget = 'seek'; + const tSec = clientXToTime(e.clientX); + video.currentTime = tSec; + updatePlayhead(tSec); + }); + + window.addEventListener('pointermove', (e) => { + if (!dragTarget || !state.loaded) return; + const dur = state.loaded.info.duration; + const tSec = clientXToTime(e.clientX); + if (dragTarget === 'seek') { + video.currentTime = tSec; + updatePlayhead(tSec); + } else if (dragTarget === 'start') { + const clamped = clampTrimRange(tSec, state.trimEnd, dur); + state.trimStart = clamped.start; + video.currentTime = clamped.start; + updateTimelineVisuals(); + updatePlayhead(clamped.start); + } else if (dragTarget === 'end') { + const clamped = clampTrimRange(state.trimStart, tSec, dur); + state.trimEnd = clamped.end; + video.currentTime = clamped.end; + updateTimelineVisuals(); + updatePlayhead(clamped.end); + } + }); + window.addEventListener('pointerup', () => { + if (dragTarget === 'start' || dragTarget === 'end') { + void refreshCLI(); + } + dragTarget = null; + }); + + // Trim manual inputs + el<'input'>('trimStartInput').addEventListener('change', () => { + if (!state.loaded) return; + const parsed = parseTimecode(el<'input'>('trimStartInput').value); + if (parsed !== null) { + const clamped = clampTrimRange(parsed, state.trimEnd, state.loaded.info.duration); + state.trimStart = clamped.start; + state.trimEnd = clamped.end; + video.currentTime = clamped.start; + void refreshCLI(); + } + updateTimelineVisuals(); + }); + el<'input'>('trimEndInput').addEventListener('change', () => { + if (!state.loaded) return; + const parsed = parseTimecode(el<'input'>('trimEndInput').value); + if (parsed !== null) { + const clamped = clampTrimRange(state.trimStart, parsed, state.loaded.info.duration); + state.trimStart = clamped.start; + state.trimEnd = clamped.end; + video.currentTime = clamped.end; + void refreshCLI(); + } + updateTimelineVisuals(); + }); + el<'button'>('resetTrimBtn').addEventListener('click', () => { + if (!state.loaded) return; + state.trimStart = 0; + state.trimEnd = state.loaded.info.duration; + updateTimelineVisuals(); + void refreshCLI(); + }); + + // Settings listeners + el<'select'>('encoderSelect').addEventListener('change', (e) => { + state.encoder = (e.target as HTMLSelectElement).value; + syncControlsFromState(); + }); + el<'select'>('hwDecodeSelect').addEventListener('change', (e) => { + state.hwDecode = (e.target as HTMLSelectElement).value; + void refreshCLI(); + }); + el<'input'>('crfRange').addEventListener('input', (e) => { + state.crf = Number((e.target as HTMLInputElement).value); + el<'span'>('crfLabel').textContent = `${state.crf} · ${crfQualityHint(state.crf)}`; + void refreshCLI(); + }); + el<'select'>('presetSelect').addEventListener('change', (e) => { + state.preset = (e.target as HTMLSelectElement).value; + void refreshCLI(); + }); + el<'select'>('tuneSelect').addEventListener('change', (e) => { + state.tune = (e.target as HTMLSelectElement).value; + void refreshCLI(); + }); + el<'select'>('fpsSelect').addEventListener('change', (e) => { + state.fps = Number((e.target as HTMLSelectElement).value); + void refreshCLI(); + }); + + document.querySelectorAll('#speedPills button').forEach((b) => { + b.addEventListener('click', () => { + state.speed = Number(b.dataset.speed); + syncControlsFromState(); + }); + }); + el<'input'>('speedRange').addEventListener('input', (e) => { + state.speed = Number((e.target as HTMLInputElement).value); + syncControlsFromState(); + }); + + document.querySelectorAll('#audioModeBtns button').forEach((b) => { + b.addEventListener('click', () => { + state.audioMode = b.dataset.amode as 'merge' | 'track' | 'mute'; + syncControlsFromState(); + }); + }); + + // Crop controls + el<'input'>('cropCheck').addEventListener('change', (e) => { + state.cropEnabled = (e.target as HTMLInputElement).checked; + syncControlsFromState(); + }); + const bindCropNum = (id: string, key: 'cropX' | 'cropY' | 'cropW' | 'cropH') => { + el<'input'>(id).addEventListener('change', (e) => { + const val = Math.max(0, Math.round(Number((e.target as HTMLInputElement).value) / 2) * 2); + state[key] = val; + if (state.loaded) { + state.cropX = Math.min(state.cropX, Math.max(0, state.loaded.info.width - 2)); + state.cropY = Math.min(state.cropY, Math.max(0, state.loaded.info.height - 2)); + state.cropW = Math.max(2, Math.min(state.cropW, state.loaded.info.width - state.cropX)); + state.cropH = Math.max(2, Math.min(state.cropH, state.loaded.info.height - state.cropY)); + } + syncControlsFromState(); + }); + }; + bindCropNum('cropX', 'cropX'); + bindCropNum('cropY', 'cropY'); + bindCropNum('cropW', 'cropW'); + bindCropNum('cropH', 'cropH'); + + document.querySelectorAll('#cropPresets button').forEach((b) => { + b.addEventListener('click', () => { + if (!state.loaded) return; + const iw = state.loaded.info.width; + const ih = state.loaded.info.height; + const r = b.dataset.ratio; + if (r === 'reset') { + state.cropX = 0; + state.cropY = 0; + state.cropW = iw & ~1; + state.cropH = ih & ~1; + } else if (r) { + const [rw, rh] = r.split(':').map(Number); + const targetRatio = rw / rh; + let w = iw; + let h = Math.round(w / targetRatio); + if (h > ih) { + h = ih; + w = Math.round(h * targetRatio); + } + w &= ~1; + h &= ~1; + state.cropW = w; + state.cropH = h; + state.cropX = Math.round((iw - w) / 2) & ~1; + state.cropY = Math.round((ih - h) / 2) & ~1; + } + syncControlsFromState(); + }); + }); + + // Visual crop drag + let cropDrag: { mode: string; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null = null; + el<'div'>('cropBox').addEventListener('pointerdown', (e) => { + if (!state.loaded) return; + e.stopPropagation(); + const target = e.target as HTMLElement; + const mode = target.dataset.handle || 'move'; + cropDrag = { + mode, + startX: e.clientX, + startY: e.clientY, + origX: state.cropX, + origY: state.cropY, + origW: state.cropW, + origH: state.cropH, + }; + }); + window.addEventListener('pointermove', (e) => { + if (!cropDrag || !state.loaded) return; + const stage = el<'div'>('videoStage').getBoundingClientRect(); + if (stage.width <= 0 || stage.height <= 0) return; + const iw = state.loaded.info.width; + const ih = state.loaded.info.height; + const dx = Math.round(((e.clientX - cropDrag.startX) / stage.width) * iw / 2) * 2; + const dy = Math.round(((e.clientY - cropDrag.startY) / stage.height) * ih / 2) * 2; + + if (cropDrag.mode === 'move') { + state.cropX = Math.max(0, Math.min(iw - cropDrag.origW, cropDrag.origX + dx)) & ~1; + state.cropY = Math.max(0, Math.min(ih - cropDrag.origH, cropDrag.origY + dy)) & ~1; + } else { + let nx = cropDrag.origX; + let ny = cropDrag.origY; + let nw = cropDrag.origW; + let nh = cropDrag.origH; + if (cropDrag.mode.includes('e')) nw = Math.max(16, Math.min(iw - nx, cropDrag.origW + dx)); + if (cropDrag.mode.includes('s')) nh = Math.max(16, Math.min(ih - ny, cropDrag.origH + dy)); + if (cropDrag.mode.includes('w')) { + const maxShift = cropDrag.origW - 16; + const shift = Math.max(-cropDrag.origX, Math.min(maxShift, dx)); + nx = cropDrag.origX + shift; + nw = cropDrag.origW - shift; + } + if (cropDrag.mode.includes('n')) { + const maxShift = cropDrag.origH - 16; + const shift = Math.max(-cropDrag.origY, Math.min(maxShift, dy)); + ny = cropDrag.origY + shift; + nh = cropDrag.origH - shift; + } + state.cropX = nx & ~1; + state.cropY = ny & ~1; + state.cropW = Math.max(16, nw & ~1); + state.cropH = Math.max(16, nh & ~1); + } + syncControlsFromState(); + }); + window.addEventListener('pointerup', () => { + cropDrag = null; + }); + + // Output & CLI + el<'input'>('outputInput').addEventListener('input', (e) => { + state.outputPath = (e.target as HTMLInputElement).value; + void refreshCLI(); + }); + el<'button'>('browseOutBtn').addEventListener('click', async () => { + const chosen = await api?.SaveVideoDialog(state.outputPath || 'output.mp4'); + if (chosen) { + state.outputPath = chosen; + el<'input'>('outputInput').value = chosen; + void refreshCLI(); + } + }); + el<'button'>('toggleCliBtn').addEventListener('click', () => { + state.cliExpanded = !state.cliExpanded; + el<'pre'>('cliBox').classList.toggle('hidden', !state.cliExpanded); + el<'button'>('copyCliBtn').classList.toggle('hidden', !state.cliExpanded); + }); + el<'button'>('copyCliBtn').addEventListener('click', async () => { + if (state.cliText) { + await navigator.clipboard.writeText(state.cliText); + const btn = el<'button'>('copyCliBtn'); + btn.textContent = t().copied; + setTimeout(() => { + btn.textContent = t().copy; + }, 1500); + } + }); + + // Convert / Cancel + el<'button'>('convertBtn').addEventListener('click', async () => { + if (!state.loaded || state.converting) return; + state.errorMsg = ''; + state.donePath = ''; + el<'div'>('errorBanner').classList.add('hidden'); + el<'div'>('doneBanner').classList.add('hidden'); + state.converting = true; + el<'button'>('convertBtn').disabled = true; + el<'button'>('cancelBtn').classList.remove('hidden'); + el<'div'>('progressWrap').classList.remove('hidden'); + el<'div'>('progressFill').style.width = '0%'; + el<'span'>('progressLeft').textContent = '0%'; + el<'span'>('progressRight').textContent = ''; + + try { + await api?.StartConvert(buildRequest()); + } catch (err) { + state.converting = false; + el<'button'>('convertBtn').disabled = false; + el<'button'>('cancelBtn').classList.add('hidden'); + el<'div'>('progressWrap').classList.add('hidden'); + showError(String(err)); + } + }); + + el<'button'>('cancelBtn').addEventListener('click', async () => { + await api?.CancelConvert(); + }); + + el<'button'>('openFolderBtn').addEventListener('click', () => { + if (state.donePath) void api?.OpenInExplorer(state.donePath); + }); + el<'button'>('playOutBtn').addEventListener('click', () => { + if (state.donePath) void api?.OpenOutputFile(state.donePath); + }); + + // Keyboard shortcuts + window.addEventListener('keydown', (e) => { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; + if (!state.loaded) return; + + if (e.code === 'Space') { + e.preventDefault(); + togglePlay(); + } else if (e.code === 'ArrowLeft') { + e.preventDefault(); + video.pause(); + video.currentTime = Math.max(0, video.currentTime - (e.shiftKey ? 1 : 0.1)); + } else if (e.code === 'ArrowRight') { + e.preventDefault(); + video.pause(); + video.currentTime = Math.min(state.loaded.info.duration, video.currentTime + (e.shiftKey ? 1 : 0.1)); + } else if (e.code === 'KeyI') { + e.preventDefault(); + el<'button'>('setStartBtn').click(); + } else if (e.code === 'KeyO') { + e.preventDefault(); + el<'button'>('setEndBtn').click(); + } + }); +} + +function setupWailsEvents() { + const rt = window.runtime; + if (!rt) return; + + rt.EventsOn('app:file-dropped', (filePath: string) => { + void loadVideoFile(filePath); + }); + + rt.EventsOn('convert:progress', (p: ProgressEvent) => { + state.progress = p; + state.doneBytes = p.outputBytes || p.totalSize || state.doneBytes; + el<'div'>('progressFill').style.width = `${p.percent.toFixed(1)}%`; + el<'span'>('progressLeft').textContent = `${p.percent.toFixed(1)}% · ${p.speed || '-'} · ${p.fps ? p.fps.toFixed(0) + 'fps' : ''}`; + const etaStr = p.etaSec > 0 ? `${t().eta}: ${formatTimecode(p.etaSec, false)}` : ''; + const sizeStr = p.totalSize > 0 ? formatBytes(p.totalSize) : ''; + el<'span'>('progressRight').textContent = [sizeStr, etaStr].filter(Boolean).join(' · '); + }); + + rt.EventsOn('convert:done', (outPath: string) => { + state.converting = false; + state.donePath = outPath; + el<'button'>('convertBtn').disabled = false; + el<'button'>('cancelBtn').classList.add('hidden'); + el<'div'>('progressWrap').classList.add('hidden'); + + const srcBytes = state.loaded?.info.sizeBytes ?? 0; + const outBytes = state.doneBytes; + let stat = formatBytes(outBytes); + if (srcBytes > 0 && outBytes > 0) { + const diffPct = Math.round(((outBytes - srcBytes) / srcBytes) * 100); + stat = `${formatBytes(srcBytes)} → ${formatBytes(outBytes)} (${diffPct > 0 ? '+' : ''}${diffPct}%)`; + } + el<'span'>('doneStats').textContent = stat; + el<'div'>('doneBanner').classList.remove('hidden'); + }); + + rt.EventsOn('convert:error', (errMsg: string) => { + state.converting = false; + el<'button'>('convertBtn').disabled = false; + el<'button'>('cancelBtn').classList.add('hidden'); + el<'div'>('progressWrap').classList.add('hidden'); + showError(errMsg); + }); +} + +async function init() { + renderShell(); + setupWailsEvents(); + + const api = appApi(); + if (api) { + try { + const info = await api.GetAppInfo(); + state.appInfo = info; + if (info.config.language === 'en' || info.config.language === 'ru') { + state.lang = info.config.language; + } + state.encoder = info.config.defaultEncoder || 'libx264'; + state.hwDecode = info.config.defaultHwDecode || 'none'; + state.preset = info.config.defaultPreset || 'slow'; + state.crf = info.config.defaultCrf ?? 20; + state.fps = info.config.defaultFps ?? 60; + state.tune = info.config.defaultTune || 'film'; + + renderShell(); + el<'span'>('verBadge').textContent = `v${info.version}`; + updateFFmpegBadge(); + syncControlsFromState(); + + if (info.initialFile) { + await loadVideoFile(info.initialFile); + } + } catch (err) { + showError(String(err)); + } + } +} + +void init(); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..f9c4ccd --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,684 @@ +:root { + --bg: #0b0f17; + --panel: #111827; + --panel-2: #161f30; + --border: #243044; + --text: #f1f5f9; + --muted: #94a3b8; + --cyan: #06b6d4; + --cyan-glow: rgba(6, 182, 212, 0.25); + --violet: #8b5cf6; + --green: #10b981; + --red: #ef4444; + --amber: #f59e0b; + --radius: 10px; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 13px; + user-select: none; + overflow: hidden; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Top Bar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-icon { + width: 28px; + height: 28px; + border-radius: 7px; + background: linear-gradient(135deg, var(--cyan), var(--violet)); + display: grid; + place-items: center; + font-weight: 800; + color: #fff; + font-size: 14px; +} + +.brand-title { + font-weight: 700; + font-size: 15px; + letter-spacing: 0.2px; +} + +.badge { + font-size: 11px; + padding: 2px 7px; + border-radius: 999px; + background: var(--panel-2); + border: 1px solid var(--border); + color: var(--muted); +} + +.topbar-center { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; + justify-content: center; +} + +.file-pill { + display: flex; + align-items: center; + gap: 8px; + background: var(--panel-2); + border: 1px solid var(--border); + padding: 5px 12px; + border-radius: 999px; + max-width: 520px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.file-pill strong { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; +} + +.file-meta { + color: var(--muted); + font-size: 11px; + font-family: var(--mono); +} + +.topbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +/* Buttons */ +button { + font-family: inherit; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + padding: 6px 12px; + border-radius: 7px; + cursor: pointer; + transition: all 0.14s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +button:hover:not(:disabled) { + border-color: var(--cyan); + background: #1c283d; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: linear-gradient(135deg, #0891b2, #4f46e5); + border-color: transparent; + color: #fff; +} + +button.primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +button.danger { + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.5); + color: #fca5a5; +} + +button.pill-active { + background: var(--cyan-glow); + border-color: var(--cyan); + color: #cffafe; +} + +.status-pill { + font-size: 11px; + padding: 5px 10px; + border-radius: 999px; +} + +.status-pill.ok { + background: rgba(16, 185, 129, 0.14); + border-color: rgba(16, 185, 129, 0.4); + color: #6ee7b7; +} + +.status-pill.warn { + background: rgba(245, 158, 11, 0.16); + border-color: rgba(245, 158, 11, 0.5); + color: #fcd34d; +} + +/* Main Layout */ +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 390px; + flex: 1; + min-height: 0; +} + +/* Left Stage */ +.stage-col { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 14px; + gap: 12px; +} + +.player-wrap { + position: relative; + flex: 1; + min-height: 240px; + background: #05070b; + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.video-stage { + position: relative; + display: inline-flex; + max-width: 100%; + max-height: 100%; +} + +video { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + background: #000; +} + +.drop-overlay { + position: absolute; + inset: 14px; + border: 2px dashed var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + text-align: center; + color: var(--muted); + background: rgba(11, 15, 23, 0.85); + cursor: pointer; +} + +.drop-overlay.drag-over { + border-color: var(--cyan); + background: rgba(6, 182, 212, 0.08); + color: var(--text); +} + +.drop-overlay h2 { + margin: 0; + font-size: 18px; + color: var(--text); +} + +.proxy-banner { + position: absolute; + top: 10px; + left: 10px; + background: rgba(15, 23, 42, 0.85); + border: 1px solid var(--cyan); + color: #cffafe; + padding: 5px 10px; + border-radius: 6px; + font-size: 11px; + z-index: 5; +} + +/* Crop Overlay */ +.crop-overlay { + position: absolute; + inset: 0; + pointer-events: none; +} + +.crop-box { + position: absolute; + border: 2px solid var(--cyan); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.58); + cursor: move; + pointer-events: auto; +} + +.crop-handle { + position: absolute; + width: 11px; + height: 11px; + background: #fff; + border: 2px solid var(--cyan); + border-radius: 2px; +} +.crop-handle.nw { top: -6px; left: -6px; cursor: nwse-resize; } +.crop-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; } +.crop-handle.sw { bottom: -6px; left: -6px; cursor: nesw-resize; } +.crop-handle.se { bottom: -6px; right: -6px; cursor: nwse-resize; } + +/* Transport Bar */ +.transport { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; +} + +.transport-group { + display: flex; + align-items: center; + gap: 6px; +} + +.time-readout { + font-family: var(--mono); + font-size: 12px; + color: var(--text); + background: var(--bg); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--border); +} + +/* Timeline & Trim */ +.timeline-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.timeline-track { + position: relative; + height: 54px; + background: #070a10; + border: 1px solid var(--border); + border-radius: 7px; + overflow: visible; + cursor: pointer; +} + +.filmstrip { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(12, 1fr); + overflow: hidden; + border-radius: 6px; +} + +.filmstrip img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.72; + pointer-events: none; +} + +.trim-dim-left, +.trim-dim-right { + position: absolute; + top: 0; + bottom: 0; + background: rgba(5, 7, 11, 0.72); + pointer-events: none; +} + +.trim-dim-left { left: 0; border-radius: 6px 0 0 6px; } +.trim-dim-right { right: 0; border-radius: 0 6px 6px 0; } + +.trim-selection { + position: absolute; + top: 0; + bottom: 0; + border-top: 2px solid var(--cyan); + border-bottom: 2px solid var(--cyan); + pointer-events: none; +} + +.trim-handle { + position: absolute; + top: -3px; + bottom: -3px; + width: 12px; + transform: translateX(-50%); + cursor: ew-resize; + z-index: 4; + display: flex; + align-items: center; + justify-content: center; +} + +.trim-handle::before { + content: ''; + width: 6px; + height: 100%; + border-radius: 3px; +} + +.trim-handle.start::before { + background: var(--cyan); + box-shadow: 0 0 8px var(--cyan); +} + +.trim-handle.end::before { + background: var(--violet); + box-shadow: 0 0 8px var(--violet); +} + +.playhead { + position: absolute; + top: -5px; + bottom: -5px; + width: 2px; + background: #fff; + transform: translateX(-50%); + pointer-events: none; + z-index: 5; + box-shadow: 0 0 6px rgba(255, 255, 255, 0.8); +} + +.playhead::before { + content: ''; + position: absolute; + top: -2px; + left: -4px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #fff; +} + +.trim-inputs { + display: grid; + grid-template-columns: 1fr 1fr auto auto auto; + gap: 8px; + align-items: end; +} + +.field-mini { + display: flex; + flex-direction: column; + gap: 3px; +} + +.field-mini label { + font-size: 11px; + color: var(--muted); +} + +.stat-chip { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 9px; + font-family: var(--mono); + font-size: 12px; + white-space: nowrap; +} + +/* Right Settings Panel */ +.settings-col { + background: var(--panel); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; +} + +.settings-scroll { + flex: 1; + overflow-y: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.card { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.card-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted); + display: flex; + align-items: center; + justify-content: space-between; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.grid-4 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field label { + font-size: 11px; + color: var(--muted); + display: flex; + justify-content: space-between; +} + +input[type='text'], +input[type='number'], +select { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 6px 8px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + outline: none; +} + +input[type='text']:focus, +input[type='number']:focus, +select:focus { + border-color: var(--cyan); +} + +input.mono { + font-family: var(--mono); +} + +input[type='range'] { + width: 100%; + accent-color: var(--cyan); +} + +.pill-row { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.pill-btn { + padding: 4px 8px; + font-size: 11px; + border-radius: 6px; +} + +.track-list { + display: flex; + flex-direction: column; + gap: 5px; +} + +.track-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + padding: 5px 8px; + border-radius: 6px; +} + +.input-row { + display: flex; + gap: 6px; +} + +.input-row input { + flex: 1; +} + +.cli-box { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + font-family: var(--mono); + font-size: 11px; + color: #a5f3fc; + word-break: break-all; + max-height: 90px; + overflow-y: auto; + user-select: text; +} + +/* Sticky Bottom Action */ +.action-footer { + padding: 12px 14px; + border-top: 1px solid var(--border); + background: var(--panel); + display: flex; + flex-direction: column; + gap: 8px; +} + +.progress-wrap { + display: flex; + flex-direction: column; + gap: 5px; +} + +.progress-bar-bg { + height: 8px; + background: var(--bg); + border-radius: 999px; + overflow: hidden; + border: 1px solid var(--border); +} + +.progress-bar-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--cyan), var(--violet)); + transition: width 0.15s linear; +} + +.progress-meta { + display: flex; + justify-content: space-between; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} + +.done-banner { + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.45); + border-radius: 8px; + padding: 9px 10px; + display: flex; + flex-direction: column; + gap: 7px; +} + +.error-banner { + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.45); + border-radius: 8px; + padding: 8px 10px; + color: #fca5a5; + font-size: 11px; + word-break: break-word; + user-select: text; +} + +.hidden { + display: none !important; +} diff --git a/frontend/src/timecode.test.ts b/frontend/src/timecode.test.ts new file mode 100644 index 0000000..226e304 --- /dev/null +++ b/frontend/src/timecode.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatTimecode, parseTimecode, formatBytes, clampTrimRange } from './timecode.ts'; + +describe('timecode helpers', () => { + it('formats seconds to HH:MM:SS.mmm', () => { + assert.equal(formatTimecode(0), '00:00:00.000'); + assert.equal(formatTimecode(65.25), '00:01:05.250'); + assert.equal(formatTimecode(3661.5), '01:01:01.500'); + assert.equal(formatTimecode(125, false), '02:05'); + }); + + it('parses numeric seconds and HH:MM:SS.mmm', () => { + assert.equal(parseTimecode('42.5'), 42.5); + assert.equal(parseTimecode('01:05.250'), 65.25); + assert.equal(parseTimecode('01:02:03.5'), 3723.5); + assert.equal(parseTimecode('invalid'), null); + assert.equal(parseTimecode('00:75:00'), null); + }); + + it('clamps trim range cleanly', () => { + assert.deepEqual(clampTrimRange(-5, 200, 60), { start: 0, end: 60 }); + assert.deepEqual(clampTrimRange(30, 30, 60, 0.5), { start: 30, end: 30.5 }); + assert.deepEqual(clampTrimRange(59.9, 60, 60, 0.5), { start: 59.5, end: 60 }); + }); + + it('formats bytes', () => { + assert.equal(formatBytes(0), '0 B'); + assert.equal(formatBytes(1024 * 1024 * 25.4), '25.4 MB'); + }); +}); diff --git a/frontend/src/timecode.ts b/frontend/src/timecode.ts new file mode 100644 index 0000000..ca02f2e --- /dev/null +++ b/frontend/src/timecode.ts @@ -0,0 +1,94 @@ +export function formatTimecode(seconds: number, includeMillis = true): string { + if (!Number.isFinite(seconds) || seconds < 0) seconds = 0; + const totalMs = Math.round(seconds * 1000); + const ms = totalMs % 1000; + const totalSec = Math.floor(totalMs / 1000); + const s = totalSec % 60; + const totalMin = Math.floor(totalSec / 60); + const m = totalMin % 60; + const h = Math.floor(totalMin / 60); + + const hh = String(h).padStart(2, '0'); + const mm = String(m).padStart(2, '0'); + const ss = String(s).padStart(2, '0'); + if (!includeMillis) { + return h > 0 ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`; + } + const mmm = String(ms).padStart(3, '0'); + return `${hh}:${mm}:${ss}.${mmm}`; +} + +export function parseTimecode(input: string): number | null { + const s = input.trim(); + if (!s) return null; + + if (/^\d+(\.\d+)?$/.test(s)) { + const val = Number(s); + return Number.isFinite(val) && val >= 0 ? val : null; + } + + const parts = s.split(':'); + if (parts.length < 2 || parts.length > 3) return null; + + let h = 0; + let m = 0; + let sec = 0; + if (parts.length === 2) { + m = Number(parts[0]); + sec = Number(parts[1]); + } else { + h = Number(parts[0]); + m = Number(parts[1]); + sec = Number(parts[2]); + } + + if ( + !Number.isFinite(h) || + !Number.isFinite(m) || + !Number.isFinite(sec) || + h < 0 || + m < 0 || + m >= 60 || + sec < 0 || + sec >= 60 + ) { + return null; + } + + return h * 3600 + m * 60 + sec; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let idx = 0; + let val = bytes; + while (val >= 1024 && idx < units.length - 1) { + val /= 1024; + idx++; + } + return `${val >= 100 || idx === 0 ? val.toFixed(0) : val.toFixed(1)} ${units[idx]}`; +} + +export function clampTrimRange( + start: number, + end: number, + duration: number, + minGap = 0.05 +): { start: number; end: number } { + const dur = Math.max(0, duration); + let s = Math.max(0, Math.min(start, dur)); + let e = Math.max(0, Math.min(end, dur)); + if (dur <= minGap) { + return { start: 0, end: dur }; + } + if (e - s < minGap) { + if (s + minGap <= dur) { + e = s + minGap; + } else { + s = Math.max(0, dur - minGap); + e = dur; + } + } + return { start: s, end: e }; +} diff --git a/frontend/src/wails.d.ts b/frontend/src/wails.d.ts new file mode 100644 index 0000000..81f5ada --- /dev/null +++ b/frontend/src/wails.d.ts @@ -0,0 +1,132 @@ +export interface AudioStream { + index: number; + audioOrder: number; + codec: string; + channels: number; + channelLayout: string; + sampleRate: string; + language: string; + title: string; +} + +export interface VideoInfo { + path: string; + fileName: string; + extension: string; + container: string; + sizeBytes: number; + duration: number; + bitRate: number; + width: number; + height: number; + fps: number; + videoCodec: string; + pixFmt: string; + audioStreams: AudioStream[]; + browserNative: boolean; +} + +export interface Binaries { + ffmpeg: string; + ffprobe: string; + source: string; +} + +export interface AppConfig { + language: 'ru' | 'en' | string; + ffmpegPath: string; + ffprobePath: string; + defaultEncoder: string; + defaultHwDecode: string; + defaultPreset: string; + defaultCrf: number; + defaultFps: number; + defaultTune: string; + defaultMergeAudio: boolean; + outputSuffix: string; +} + +export interface AppInfo { + version: string; + binaries: Binaries; + config: AppConfig; + initialFile: string; +} + +export interface LoadedVideo { + info: VideoInfo; + videoId: string; + previewUrl: string; + proxyUrl: string; + proxyMergedUrl: string; + thumbBaseUrl: string; + defaultOutputPath: string; +} + +export interface CropRect { + enabled: boolean; + x: number; + y: number; + w: number; + h: number; +} + +export interface ConvertRequest { + inputPath: string; + outputPath: string; + trimStart: number; + trimEnd: number; + encoder: string; + hwDecode: string; + preset: string; + crf: number; + tune: string; + fps: number; + speed: number; + audioMode: 'merge' | 'track' | 'mute'; + audioTrack: number; + selectedTracks: number[]; + crop: CropRect; + fastStart: boolean; + overwrite: boolean; +} + +export interface ProgressEvent { + percent: number; + outTimeSec: number; + totalSec: number; + fps: number; + speed: string; + totalSize: number; + etaSec: number; + done: boolean; + error?: string; + outputPath?: string; + outputBytes?: number; +} + +declare global { + interface Window { + go?: { + main?: { + App?: { + GetAppInfo(): Promise; + RefreshBinaries(): Promise; + SaveConfig(cfg: AppConfig): Promise; + OpenVideoDialog(): Promise; + SaveVideoDialog(defaultPath: string): Promise; + SelectFFmpegDialog(): Promise; + LoadVideo(path: string): Promise; + PreviewCommand(req: ConvertRequest): Promise; + StartConvert(req: ConvertRequest): Promise; + CancelConvert(): Promise; + OpenInExplorer(path: string): Promise; + OpenOutputFile(path: string): Promise; + }; + }; + }; + runtime?: { + EventsOn(eventName: string, callback: (...args: any[]) => void): () => void; + }; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ea70aeb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..043d214 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2022', + }, +}); diff --git a/go.mod b/go.mod index afccd6a..6f37056 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,35 @@ module convert -go 1.17 +go 1.24 + +require github.com/wailsapp/wails/v2 v2.10.2 + +require ( + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.19 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b1e0229 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= +github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk= +github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 3e48c51..0000000 --- a/install.ps1 +++ /dev/null @@ -1,39 +0,0 @@ -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$OutputEncoding = [System.Text.Encoding]::UTF8 - -function pressEnterToExit { - Write-Host "Press Enter to exit... " -NoNewline - [void][System.Console]::ReadLine() -} - -$installPath = Join-Path $HOME "programs\convert" -New-Item -Force -ItemType Directory $installPath | Out-Null - -$executablePath = Join-Path $installPath convert.exe -go build -o $executablePath - -if ($LASTEXITCODE -eq 0) { - Write-Host "✅ built: $executablePath" -} else { - Write-Host "❌ build error" - pressEnterToExit - exit $LASTEXITCODE -} - -reg add "HKCU\Software\Classes\SystemFileAssociations\.mp4\shell\convert" /ve /d "convert" /f > $null -if ($LASTEXITCODE -ne 0) { - Write-Host "❌ reg error" - pressEnterToExit - exit $LASTEXITCODE -} - -$command = $executablePath + " -i `"%1`"" -reg add "HKCU\Software\Classes\SystemFileAssociations\.mp4\shell\convert\command" /ve /d $command /f > $null -if ($LASTEXITCODE -ne 0) { - Write-Host "❌ reg error" - pressEnterToExit - exit $LASTEXITCODE -} - -Write-Host "✅ reg values added" -pressEnterToExit diff --git a/installer/installer.nsi b/installer/installer.nsi new file mode 100644 index 0000000..64b696f --- /dev/null +++ b/installer/installer.nsi @@ -0,0 +1,138 @@ +Unicode True +!include "MUI2.nsh" + +!ifndef VERSION + !define VERSION "2.0.0" +!endif + +!define APP_NAME "ConvertGUI" +!define EXE_NAME "convert.exe" +!define UNINSTALL_REG "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" + +Name "${APP_NAME} ${VERSION}" +OutFile "../dist/ConvertGUI-Setup-${VERSION}.exe" +InstallDir "$LOCALAPPDATA\Programs\${APP_NAME}" +InstallDirRegKey HKCU "Software\${APP_NAME}" "InstallDir" +RequestExecutionLevel user +SetCompressor /SOLID lzma + +!define MUI_ABORTWARNING +!define MUI_ICON "../build/windows/icon.ico" +!define MUI_UNICON "../build/windows/icon.ico" + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!define MUI_FINISHPAGE_RUN "$INSTDIR\${EXE_NAME}" +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" +!insertmacro MUI_LANGUAGE "Russian" + +!macro RegisterExt EXT + WriteRegStr HKCU "Software\Classes\SystemFileAssociations\${EXT}\shell\${APP_NAME}" "" "Convert with ${APP_NAME}" + WriteRegStr HKCU "Software\Classes\SystemFileAssociations\${EXT}\shell\${APP_NAME}" "Icon" "$INSTDIR\${EXE_NAME},0" + WriteRegStr HKCU "Software\Classes\SystemFileAssociations\${EXT}\shell\${APP_NAME}\command" "" '"$INSTDIR\${EXE_NAME}" -i "%1"' +!macroend + +!macro UnregisterExt EXT + DeleteRegKey HKCU "Software\Classes\SystemFileAssociations\${EXT}\shell\${APP_NAME}" +!macroend + +Section "ConvertGUI Application (Required)" SEC_APP + SectionIn RO + SetOutPath "$INSTDIR" + File "../dist/convert.exe" + + WriteRegStr HKCU "Software\${APP_NAME}" "InstallDir" "$INSTDIR" + WriteUninstaller "$INSTDIR\uninstall.exe" + + WriteRegStr HKCU "${UNINSTALL_REG}" "DisplayName" "${APP_NAME}" + WriteRegStr HKCU "${UNINSTALL_REG}" "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "${UNINSTALL_REG}" "Publisher" "m4xx1m" + WriteRegStr HKCU "${UNINSTALL_REG}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKCU "${UNINSTALL_REG}" "DisplayIcon" "$INSTDIR\${EXE_NAME},0" + WriteRegDWORD HKCU "${UNINSTALL_REG}" "NoModify" 1 + WriteRegDWORD HKCU "${UNINSTALL_REG}" "NoRepair" 1 + + CreateDirectory "$SMPROGRAMS\${APP_NAME}" + CreateShortcut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" "$INSTDIR\${EXE_NAME}" + CreateShortcut "$SMPROGRAMS\${APP_NAME}\Uninstall ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe" + + ; Check Microsoft Edge WebView2 Runtime (required on older Windows 10) + SetRegView 32 + ReadRegStr $0 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + StrCmp $0 "" 0 webview2_done + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + StrCmp $0 "" 0 webview2_done + DetailPrint "Installing Microsoft Edge WebView2 Runtime..." + nsExec::ExecToLog 'powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://go.microsoft.com/fwlink/p/?LinkId=2124703 -OutFile $env:TEMP\wv2setup.exe; Start-Process $env:TEMP\wv2setup.exe -ArgumentList /silent,/install -Wait; Remove-Item $env:TEMP\wv2setup.exe -ErrorAction SilentlyContinue"' + webview2_done: +SectionEnd + +Section "Bundled FFmpeg & FFprobe" SEC_FFMPEG + SetOutPath "$INSTDIR\bin" + File "../dist/bin/ffmpeg.exe" + File "../dist/bin/ffprobe.exe" +SectionEnd + +Section "Windows Explorer Context Menu" SEC_CTX + !insertmacro RegisterExt ".mp4" + !insertmacro RegisterExt ".mkv" + !insertmacro RegisterExt ".mov" + !insertmacro RegisterExt ".avi" + !insertmacro RegisterExt ".webm" + !insertmacro RegisterExt ".m4v" + !insertmacro RegisterExt ".flv" + !insertmacro RegisterExt ".ts" + !insertmacro RegisterExt ".wmv" +SectionEnd + +Section "Desktop Shortcut" SEC_DESKTOP + CreateShortcut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${EXE_NAME}" +SectionEnd + +LangString DESC_APP ${LANG_ENGLISH} "Core ConvertGUI application and Start Menu shortcut." +LangString DESC_APP ${LANG_RUSSIAN} "Основное приложение ConvertGUI и ярлык в меню Пуск." +LangString DESC_FFMPEG ${LANG_ENGLISH} "Standalone static FFmpeg & FFprobe binaries (recommended if not installed in PATH)." +LangString DESC_FFMPEG ${LANG_RUSSIAN} "Встроенные FFmpeg и FFprobe (рекомендуется, если не установлены в PATH)." +LangString DESC_CTX ${LANG_ENGLISH} "Add 'Convert with ConvertGUI' to Windows Explorer right-click menu for video files." +LangString DESC_CTX ${LANG_RUSSIAN} "Добавить «Convert with ConvertGUI» в контекстное меню видеофайлов Проводника." +LangString DESC_DESKTOP ${LANG_ENGLISH} "Create a shortcut on the Desktop." +LangString DESC_DESKTOP ${LANG_RUSSIAN} "Создать ярлык на Рабочем столе." + +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_APP} $(DESC_APP) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_FFMPEG} $(DESC_FFMPEG) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_CTX} $(DESC_CTX) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_DESKTOP} $(DESC_DESKTOP) +!insertmacro MUI_FUNCTION_DESCRIPTION_END + +Section "Uninstall" + !insertmacro UnregisterExt ".mp4" + !insertmacro UnregisterExt ".mkv" + !insertmacro UnregisterExt ".mov" + !insertmacro UnregisterExt ".avi" + !insertmacro UnregisterExt ".webm" + !insertmacro UnregisterExt ".m4v" + !insertmacro UnregisterExt ".flv" + !insertmacro UnregisterExt ".ts" + !insertmacro UnregisterExt ".wmv" + + Delete "$DESKTOP\${APP_NAME}.lnk" + RMDir /r "$SMPROGRAMS\${APP_NAME}" + + Delete "$INSTDIR\bin\ffmpeg.exe" + Delete "$INSTDIR\bin\ffprobe.exe" + RMDir "$INSTDIR\bin" + Delete "$INSTDIR\${EXE_NAME}" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR" + + DeleteRegKey HKCU "${UNINSTALL_REG}" + DeleteRegKey HKCU "Software\${APP_NAME}" +SectionEnd diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7fb5903 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type AppConfig struct { + Language string `json:"language"` // "ru" or "en" + FFmpegPath string `json:"ffmpegPath"` + FFprobePath string `json:"ffprobePath"` + DefaultEncoder string `json:"defaultEncoder"` // libx264, h264_nvenc, libx265, hevc_nvenc + DefaultHwDecode string `json:"defaultHwDecode"` + DefaultPreset string `json:"defaultPreset"` + DefaultCRF int `json:"defaultCrf"` + DefaultFPS int `json:"defaultFps"` // 0 = original, 60 = 60fps + DefaultTune string `json:"defaultTune"` + DefaultMergeAudio bool `json:"defaultMergeAudio"` + OutputSuffix string `json:"outputSuffix"` +} + +func Default() AppConfig { + return AppConfig{ + Language: "ru", + DefaultEncoder: "libx264", + DefaultHwDecode: "none", + DefaultPreset: "slow", + DefaultCRF: 20, + DefaultFPS: 60, + DefaultTune: "film", + DefaultMergeAudio: true, + OutputSuffix: "_converted", + } +} + +func ConfigPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "ConvertGUI", "config.json"), nil +} + +func Load() AppConfig { + cfg := Default() + path, err := ConfigPath() + if err != nil { + return cfg + } + data, err := os.ReadFile(path) + if err != nil { + return cfg + } + _ = json.Unmarshal(data, &cfg) + if cfg.Language == "" { + cfg.Language = "ru" + } + if cfg.DefaultEncoder == "" { + cfg.DefaultEncoder = "libx264" + } + if cfg.DefaultPreset == "" { + cfg.DefaultPreset = "slow" + } + if cfg.DefaultCRF < 0 || cfg.DefaultCRF > 51 { + cfg.DefaultCRF = 20 + } + return cfg +} + +func Save(cfg AppConfig) error { + path, err := ConfigPath() + if err != nil { + return err + } + 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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..333af06 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDefaultsAndCRFClamp(t *testing.T) { + tmp := t.TempDir() + t.Setenv("APPDATA", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp) + + cfg := Default() + if cfg.DefaultCRF != 20 || cfg.DefaultPreset != "slow" || cfg.Language != "ru" { + t.Fatalf("unexpected defaults: %+v", cfg) + } + + cfg.DefaultCRF = 99 + if err := Save(cfg); err != nil { + t.Fatalf("save failed: %v", err) + } + loaded := Load() + if loaded.DefaultCRF != 20 { + t.Fatalf("expected invalid CRF 99 to reset to 20, got %d", loaded.DefaultCRF) + } + + // CRF 0 (lossless) must be preserved + cfg.DefaultCRF = 0 + if err := Save(cfg); err != nil { + t.Fatalf("save CRF 0 failed: %v", err) + } + loadedZero := Load() + if loadedZero.DefaultCRF != 0 { + t.Fatalf("expected CRF 0 to be preserved, got %d", loadedZero.DefaultCRF) + } + + path, _ := ConfigPath() + if _, err := os.Stat(filepath.Clean(path)); err != nil { + t.Fatalf("config file missing: %v", err) + } +} diff --git a/internal/convert/command.go b/internal/convert/command.go new file mode 100644 index 0000000..4a0a669 --- /dev/null +++ b/internal/convert/command.go @@ -0,0 +1,367 @@ +package convert + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" + + "convert/internal/media" +) + +type CropRect struct { + Enabled bool `json:"enabled"` + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` +} + +type Request struct { + InputPath string `json:"inputPath"` + OutputPath string `json:"outputPath"` + TrimStart float64 `json:"trimStart"` + TrimEnd float64 `json:"trimEnd"` + Encoder string `json:"encoder"` // libx264, h264_nvenc, libx265, hevc_nvenc + HwDecode string `json:"hwDecode"` // none, nvdec, d3d11va, auto + Preset string `json:"preset"` + CRF int `json:"crf"` + Tune string `json:"tune"` // film, animation, grain, none + FPS int `json:"fps"` // 0 = keep original + Speed float64 `json:"speed"` + AudioMode string `json:"audioMode"` // merge, track, mute + AudioTrack int `json:"audioTrack"` + SelectedTracks []int `json:"selectedTracks"` + Crop CropRect `json:"crop"` + FastStart bool `json:"fastStart"` + Overwrite bool `json:"overwrite"` +} + +type BuiltCommand struct { + Args []string `json:"args"` + OutputPath string `json:"outputPath"` + ExpectedDuration float64 `json:"expectedDuration"` +} + +func DefaultOutputPath(inputPath, suffix string) string { + if suffix == "" { + suffix = "_converted" + } + dir := filepath.Dir(inputPath) + base := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath)) + candidate := filepath.Join(dir, base+suffix+".mp4") + if strings.EqualFold(filepath.Clean(candidate), filepath.Clean(inputPath)) { + candidate = filepath.Join(dir, base+"_out.mp4") + } + return candidate +} + +func formatCleanFloat(v float64) string { + rounded := math.Round(v*10000) / 10000 + return strconv.FormatFloat(rounded, 'f', -1, 64) +} + +func BuildAtempoChain(speed float64) []string { + if speed <= 0 || math.Abs(speed-1.0) < 0.001 { + return nil + } + var filters []string + rem := speed + for rem > 2.0+1e-6 { + filters = append(filters, "atempo=2") + rem /= 2.0 + } + for rem < 0.5-1e-6 { + filters = append(filters, "atempo=0.5") + rem /= 0.5 + } + if math.Abs(rem-1.0) >= 0.0001 { + filters = append(filters, "atempo="+formatCleanFloat(rem)) + } + return filters +} + +func BuildCommand(req Request, info *media.VideoInfo) (*BuiltCommand, error) { + if req.InputPath == "" { + return nil, fmt.Errorf("input path is required") + } + if info == nil { + return nil, fmt.Errorf("video info is required") + } + if req.TrimStart < 0 { + req.TrimStart = 0 + } + if req.TrimEnd > 0 && req.TrimEnd <= req.TrimStart { + return nil, fmt.Errorf("trim end (%.2fs) must be greater than trim start (%.2fs)", req.TrimEnd, req.TrimStart) + } + if req.Speed <= 0 { + req.Speed = 1.0 + } + if req.Speed < 0.25 || req.Speed > 8.0 { + return nil, fmt.Errorf("speed must be between 0.25x and 8.0x") + } + if req.CRF < 0 || req.CRF > 51 { + return nil, fmt.Errorf("CRF must be between 0 and 51") + } + if req.Encoder == "" { + req.Encoder = "libx264" + } + if req.Preset == "" { + req.Preset = "slow" + } + + outPath := strings.TrimSpace(req.OutputPath) + if outPath == "" { + outPath = DefaultOutputPath(req.InputPath, "_converted") + } + if strings.EqualFold(filepath.Clean(outPath), filepath.Clean(req.InputPath)) { + return nil, fmt.Errorf("output path cannot be the same as input path") + } + + args := []string{"-hide_banner"} + if req.Overwrite { + args = append(args, "-y") + } else { + args = append(args, "-n") + } + + // Input hardware decode args + switch strings.ToLower(req.HwDecode) { + case "nvdec": + args = append(args, "-hwaccel", "nvdec", "-threads", "1") + case "d3d11va": + args = append(args, "-hwaccel", "d3d11va") + case "auto": + args = append(args, "-hwaccel", "auto") + } + + // Trim before -i for fast seeking + if req.TrimStart > 0.001 { + args = append(args, "-ss", formatSeconds(req.TrimStart)) + } + effectiveEnd := info.Duration + if req.TrimEnd > 0.001 && (info.Duration <= 0 || req.TrimEnd < info.Duration-0.01) { + args = append(args, "-to", formatSeconds(req.TrimEnd)) + effectiveEnd = req.TrimEnd + } + if effectiveEnd <= req.TrimStart { + effectiveEnd = info.Duration + } + sourceClipDur := effectiveEnd - req.TrimStart + if sourceClipDur < 0 { + sourceClipDur = 0 + } + expectedDur := sourceClipDur / req.Speed + + args = append(args, "-i", req.InputPath) + + // Video filters + var videoFilters []string + if req.Crop.Enabled && req.Crop.W > 0 && req.Crop.H > 0 { + cx := max(0, req.Crop.X) + cy := max(0, req.Crop.Y) + cw := req.Crop.W &^ 1 + ch := req.Crop.H &^ 1 + if cw < 2 { + cw = 2 + } + if ch < 2 { + ch = 2 + } + if info.Width > 0 && cx+cw > info.Width { + cw = (info.Width - cx) &^ 1 + } + if info.Height > 0 && cy+ch > info.Height { + ch = (info.Height - cy) &^ 1 + } + if cw >= 2 && ch >= 2 { + videoFilters = append(videoFilters, fmt.Sprintf("crop=%d:%d:%d:%d", cw, ch, cx, cy)) + } + } + + if math.Abs(req.Speed-1.0) >= 0.001 { + videoFilters = append(videoFilters, fmt.Sprintf("setpts=1/%s*PTS", formatCleanFloat(req.Speed))) + } + + // Audio setup + numAudio := len(info.AudioStreams) + audioMode := strings.ToLower(req.AudioMode) + if audioMode == "" { + if numAudio > 1 { + audioMode = "merge" + } else { + audioMode = "track" + } + } + if numAudio == 0 { + audioMode = "mute" + } + + var filterComplexParts []string + var postArgs []string + + if len(videoFilters) > 0 { + filterComplexParts = append(filterComplexParts, "[0:v:0]"+strings.Join(videoFilters, ",")+"[v]") + postArgs = append(postArgs, "-map", "[v]") + } else { + postArgs = append(postArgs, "-map", "0:v:0") + } + + atempoFilters := BuildAtempoChain(req.Speed) + + switch audioMode { + case "mute": + postArgs = append(postArgs, "-an") + case "merge": + tracks := req.SelectedTracks + if len(tracks) == 0 { + for i := 0; i < numAudio; i++ { + tracks = append(tracks, i) + } + } + // Filter valid track indices + var validTracks []int + seen := map[int]bool{} + for _, idx := range tracks { + if idx >= 0 && idx < numAudio && !seen[idx] { + seen[idx] = true + validTracks = append(validTracks, idx) + } + } + if len(validTracks) == 0 { + postArgs = append(postArgs, "-an") + } else if len(validTracks) == 1 { + tIdx := validTracks[0] + if len(atempoFilters) > 0 { + filterComplexParts = append(filterComplexParts, fmt.Sprintf("[0:a:%d]%s[a]", tIdx, strings.Join(atempoFilters, ","))) + postArgs = append(postArgs, "-map", "[a]") + } else { + postArgs = append(postArgs, "-map", fmt.Sprintf("0:a:%d", tIdx)) + } + } else { + var inputs strings.Builder + for _, tIdx := range validTracks { + inputs.WriteString(fmt.Sprintf("[0:a:%d]", tIdx)) + } + chain := fmt.Sprintf("amix=inputs=%d:normalize=0", len(validTracks)) + if len(atempoFilters) > 0 { + chain += "," + strings.Join(atempoFilters, ",") + } + filterComplexParts = append(filterComplexParts, inputs.String()+chain+"[a]") + postArgs = append(postArgs, "-map", "[a]") + } + default: // "track" + tIdx := req.AudioTrack + if tIdx < 0 || tIdx >= numAudio { + tIdx = 0 + } + if len(atempoFilters) > 0 { + filterComplexParts = append(filterComplexParts, fmt.Sprintf("[0:a:%d]%s[a]", tIdx, strings.Join(atempoFilters, ","))) + postArgs = append(postArgs, "-map", "[a]") + } else { + postArgs = append(postArgs, "-map", fmt.Sprintf("0:a:%d", tIdx)) + } + } + + if len(filterComplexParts) > 0 { + args = append(args, "-filter_complex", strings.Join(filterComplexParts, ";")) + } + args = append(args, postArgs...) + + // Video codec args + switch req.Encoder { + case "h264_nvenc": + args = append(args, + "-c:v", "h264_nvenc", + "-profile:v", "high", + "-pix_fmt", "yuv420p", + "-preset", mapNvencPreset(req.Preset), + "-rc", "vbr", + "-cq", strconv.Itoa(req.CRF), + "-b:v", "0", + ) + case "libx265": + args = append(args, + "-c:v", "libx265", + "-pix_fmt", "yuv420p", + "-preset", req.Preset, + "-crf", strconv.Itoa(req.CRF), + "-tag:v", "hvc1", + ) + case "hevc_nvenc": + args = append(args, + "-c:v", "hevc_nvenc", + "-pix_fmt", "yuv420p", + "-preset", mapNvencPreset(req.Preset), + "-rc", "vbr", + "-cq", strconv.Itoa(req.CRF), + "-b:v", "0", + "-tag:v", "hvc1", + ) + default: // libx264 + args = append(args, + "-c:v", "libx264", + "-profile:v", "high", + "-pix_fmt", "yuv420p", + "-preset", req.Preset, + "-crf", strconv.Itoa(req.CRF), + ) + if req.Tune != "" && req.Tune != "none" { + args = append(args, "-tune", req.Tune) + } + } + + if audioMode != "mute" { + args = append(args, "-c:a", "aac", "-b:a", "192k") + } + + if req.FPS > 0 { + args = append(args, "-r", strconv.Itoa(req.FPS)) + } + + if req.FastStart { + args = append(args, "-movflags", "+faststart") + } + + args = append(args, outPath) + + return &BuiltCommand{ + Args: args, + OutputPath: outPath, + ExpectedDuration: expectedDur, + }, nil +} + +func mapNvencPreset(p string) string { + switch strings.ToLower(p) { + case "ultrafast", "superfast": + return "p1" + case "veryfast": + return "p2" + case "faster": + return "p3" + case "fast": + return "p4" + case "medium": + return "p5" + case "slow": + return "p6" + case "slower", "veryslow": + return "p7" + case "p1", "p2", "p3", "p4", "p5", "p6", "p7": + return p + default: + return "p6" + } +} + +func formatSeconds(sec float64) string { + return strconv.FormatFloat(sec, 'f', 3, 64) +} + +func EnsureUniqueOutputDir(outPath string) error { + dir := filepath.Dir(outPath) + return os.MkdirAll(dir, 0o755) +} diff --git a/internal/convert/command_test.go b/internal/convert/command_test.go new file mode 100644 index 0000000..f1463b4 --- /dev/null +++ b/internal/convert/command_test.go @@ -0,0 +1,200 @@ +package convert + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "convert/internal/media" +) + +func TestBuildAtempoChain(t *testing.T) { + if got := BuildAtempoChain(1.0); len(got) != 0 { + t.Fatalf("expected empty chain for 1.0, got %v", got) + } + got4 := BuildAtempoChain(4.0) + if len(got4) != 2 || got4[0] != "atempo=2" || got4[1] != "atempo=2" { + t.Fatalf("unexpected 4x chain: %v", got4) + } + gotQuarter := BuildAtempoChain(0.25) + if len(gotQuarter) != 2 || gotQuarter[0] != "atempo=0.5" || gotQuarter[1] != "atempo=0.5" { + t.Fatalf("unexpected 0.25x chain: %v", gotQuarter) + } +} + +func TestBuildCommand_MultiAudioMergeAndTrim(t *testing.T) { + info := &media.VideoInfo{ + Path: `C:\videos\clip.mp4`, + Duration: 60, + Width: 1920, + Height: 1080, + AudioStreams: []media.AudioStream{ + {Index: 1, AudioOrder: 0, Codec: "aac"}, + {Index: 2, AudioOrder: 1, Codec: "aac"}, + }, + } + req := Request{ + InputPath: `C:\videos\clip.mp4`, + TrimStart: 10, + TrimEnd: 30, + Encoder: "libx264", + HwDecode: "nvdec", + Preset: "slow", + CRF: 20, + Tune: "film", + FPS: 60, + Speed: 2.0, + AudioMode: "merge", + Crop: CropRect{Enabled: true, X: 10, Y: 10, W: 1281, H: 721}, + FastStart: true, + Overwrite: true, + } + + built, err := BuildCommand(req, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if built.ExpectedDuration != 10 { + t.Fatalf("expected 10s output duration, got %v", built.ExpectedDuration) + } + joined := strings.Join(built.Args, " ") + for _, must := range []string{ + "-hwaccel nvdec", + "-ss 10.000", + "-to 30.000", + "crop=1280:720:10:10", + "setpts=1/2*PTS", + "[0:a:0][0:a:1]amix=inputs=2:normalize=0,atempo=2[a]", + "-c:v libx264", + "-crf 20", + "-preset slow", + "-tune film", + "-r 60", + "-movflags +faststart", + } { + if !strings.Contains(joined, must) { + t.Fatalf("expected %q in args: %s", must, joined) + } + } +} + +func TestBuildCommand_ValidationErrors(t *testing.T) { + info := &media.VideoInfo{Path: "a.mp4", Duration: 30} + if _, err := BuildCommand(Request{InputPath: "a.mp4", TrimStart: 20, TrimEnd: 10, Speed: 1}, info); err == nil { + t.Fatalf("expected error when trimEnd <= trimStart") + } + if _, err := BuildCommand(Request{InputPath: "a.mp4", OutputPath: "a.mp4", Speed: 1}, info); err == nil { + t.Fatalf("expected error when output == input") + } + if _, err := BuildCommand(Request{InputPath: "a.mp4", Speed: 12}, info); err == nil { + t.Fatalf("expected error for speed > 8.0") + } +} + +func TestProgressParser(t *testing.T) { + p := newProgressParser(10.0) + lines := []string{ + "fps=120.5", + "total_size=500000", + "out_time_us=5000000", + "speed=2.0x", + "progress=continue", + } + var got Progress + var ok bool + for _, l := range lines { + got, ok = p.feed(l) + } + if !ok { + t.Fatalf("expected progress completion on progress=continue") + } + if got.Percent != 50.0 { + t.Fatalf("expected 50%%, got %v", got.Percent) + } + if got.ETASec != 2.5 { + t.Fatalf("expected ETA 2.5s, got %v", got.ETASec) + } +} + +func TestIntegration_RealFFmpegConvert(t *testing.T) { + bins := media.FindBinaries("", "") + if bins.FFmpeg == "" || bins.FFprobe == "" { + t.Skip("ffmpeg/ffprobe not installed on host") + } + + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "source.mp4") + dst := filepath.Join(tmpDir, "out.mp4") + + // Generate 4s synthetic test video with 2 audio tracks + gen := exec.Command(bins.FFmpeg, + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=4:size=640x360:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440:duration=4", + "-f", "lavfi", "-i", "sine=frequency=880:duration=4", + "-map", "0:v", "-map", "1:a", "-map", "2:a", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-y", src, + ) + if out, err := gen.CombinedOutput(); err != nil { + t.Fatalf("failed generating test video: %v (%s)", err, string(out)) + } + + info, err := media.Probe(bins.FFprobe, src) + if err != nil { + t.Fatalf("probe failed: %v", err) + } + if len(info.AudioStreams) != 2 { + t.Fatalf("expected 2 audio streams, got %d", len(info.AudioStreams)) + } + + built, err := BuildCommand(Request{ + InputPath: src, + OutputPath: dst, + TrimStart: 1.0, + TrimEnd: 3.0, + Encoder: "libx264", + Preset: "ultrafast", + CRF: 24, + FPS: 30, + Speed: 2.0, + AudioMode: "merge", + Crop: CropRect{Enabled: true, X: 10, Y: 10, W: 320, H: 180}, + FastStart: true, + Overwrite: true, + }, info) + if err != nil { + t.Fatalf("BuildCommand failed: %v", err) + } + + var lastProg Progress + runner := NewRunner() + if err := runner.Run(context.Background(), bins.FFmpeg, built, func(p Progress) { + lastProg = p + }); err != nil { + t.Fatalf("runner failed: %v", err) + } + + if !lastProg.Done || lastProg.Percent != 100 { + t.Fatalf("expected Done=true and 100%%, got %+v", lastProg) + } + if _, err := os.Stat(dst); err != nil { + t.Fatalf("output missing: %v", err) + } + + outInfo, err := media.Probe(bins.FFprobe, dst) + if err != nil { + t.Fatalf("probe output failed: %v", err) + } + if outInfo.Width != 320 || outInfo.Height != 180 { + t.Fatalf("expected cropped 320x180, got %dx%d", outInfo.Width, outInfo.Height) + } + if len(outInfo.AudioStreams) != 1 { + t.Fatalf("expected 1 merged audio stream, got %d", len(outInfo.AudioStreams)) + } + if outInfo.Duration < 0.7 || outInfo.Duration > 1.4 { + t.Fatalf("expected ~1.0s duration after 2s clip @ 2x speed, got %v", outInfo.Duration) + } +} diff --git a/internal/convert/progress.go b/internal/convert/progress.go new file mode 100644 index 0000000..998ad8b --- /dev/null +++ b/internal/convert/progress.go @@ -0,0 +1,109 @@ +package convert + +import ( + "strconv" + "strings" +) + +type Progress struct { + Percent float64 `json:"percent"` + OutTimeSec float64 `json:"outTimeSec"` + TotalSec float64 `json:"totalSec"` + FPS float64 `json:"fps"` + Speed string `json:"speed"` + TotalSize int64 `json:"totalSize"` + ETASec float64 `json:"etaSec"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` + OutputPath string `json:"outputPath,omitempty"` + OutputBytes int64 `json:"outputBytes,omitempty"` +} + +type progressParser struct { + totalSec float64 + current Progress +} + +func newProgressParser(totalSec float64) *progressParser { + return &progressParser{ + totalSec: totalSec, + current: Progress{ + TotalSec: totalSec, + }, + } +} + +// feed parses a single line from `ffmpeg -progress pipe:1` and returns (Progress, true) when a block completes. +func (p *progressParser) feed(line string) (Progress, bool) { + line = strings.TrimSpace(line) + if line == "" { + return Progress{}, false + } + eq := strings.IndexByte(line, '=') + if eq <= 0 { + return Progress{}, false + } + key := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + + switch key { + case "fps": + if v, err := strconv.ParseFloat(val, 64); err == nil { + p.current.FPS = v + } + case "total_size": + if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 { + p.current.TotalSize = v + } + case "out_time_us": + if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 { + p.current.OutTimeSec = float64(v) / 1_000_000.0 + } + case "out_time_ms": + // Note: ffmpeg's out_time_ms is actually microseconds in many versions, + // so only use if out_time_us wasn't set. + if p.current.OutTimeSec == 0 { + if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 { + p.current.OutTimeSec = float64(v) / 1_000_000.0 + } + } + case "speed": + p.current.Speed = val + case "progress": + if p.totalSec > 0 { + pct := (p.current.OutTimeSec / p.totalSec) * 100.0 + if pct > 100 { + pct = 100 + } + if pct < 0 { + pct = 0 + } + p.current.Percent = pct + + if sp := parseSpeedFactor(p.current.Speed); sp > 0 && p.current.OutTimeSec < p.totalSec { + p.current.ETASec = (p.totalSec - p.current.OutTimeSec) / sp + } else { + p.current.ETASec = 0 + } + } + if val == "end" { + p.current.Percent = 100 + p.current.ETASec = 0 + p.current.Done = true + } + return p.current, true + } + return Progress{}, false +} + +func parseSpeedFactor(speedStr string) float64 { + s := strings.TrimSpace(strings.TrimSuffix(speedStr, "x")) + if s == "" || s == "N/A" { + return 0 + } + v, err := strconv.ParseFloat(s, 64) + if err != nil || v <= 0 { + return 0 + } + return v +} diff --git a/internal/convert/runner.go b/internal/convert/runner.go new file mode 100644 index 0000000..28ef0df --- /dev/null +++ b/internal/convert/runner.go @@ -0,0 +1,168 @@ +package convert + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "convert/internal/media" +) + +type Runner struct { + mu sync.Mutex + cancel context.CancelFunc + stdin io.WriteCloser + active bool +} + +func NewRunner() *Runner { + return &Runner{} +} + +func (r *Runner) IsRunning() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.active +} + +func (r *Runner) Cancel() bool { + r.mu.Lock() + defer r.mu.Unlock() + if !r.active { + return false + } + if r.stdin != nil { + _, _ = io.WriteString(r.stdin, "q\n") + } + if r.cancel != nil { + r.cancel() + } + return true +} + +func (r *Runner) Run( + parentCtx context.Context, + ffmpegBin string, + built *BuiltCommand, + onProgress func(Progress), +) error { + if ffmpegBin == "" { + return fmt.Errorf("ffmpeg binary not found") + } + if built == nil { + return fmt.Errorf("built command is nil") + } + if err := EnsureUniqueOutputDir(built.OutputPath); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + r.mu.Lock() + if r.active { + r.mu.Unlock() + return fmt.Errorf("conversion already in progress") + } + ctx, cancel := context.WithCancel(parentCtx) + r.cancel = cancel + r.active = true + r.mu.Unlock() + + defer func() { + r.mu.Lock() + r.active = false + r.cancel = nil + r.stdin = nil + r.mu.Unlock() + cancel() + }() + + args := append([]string{"-progress", "pipe:1", "-nostats"}, built.Args...) + cmd := exec.CommandContext(ctx, ffmpegBin, args...) + media.HideCmdWindow(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stdin, err := cmd.StdinPipe() + if err == nil { + r.mu.Lock() + r.stdin = stdin + r.mu.Unlock() + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &limitedBuffer{buf: &stderrBuf, max: 32 * 1024} + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffmpeg: %w", err) + } + + parser := newProgressParser(built.ExpectedDuration) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if prog, ok := parser.feed(scanner.Text()); ok && onProgress != nil { + prog.OutputPath = built.OutputPath + onProgress(prog) + } + } + + waitErr := cmd.Wait() + if ctx.Err() != nil { + _ = os.Remove(built.OutputPath) + return fmt.Errorf("conversion cancelled") + } + if waitErr != nil { + _ = os.Remove(built.OutputPath) + errTail := strings.TrimSpace(stderrBuf.String()) + if len(errTail) > 800 { + errTail = errTail[len(errTail)-800:] + } + if errTail != "" { + return fmt.Errorf("ffmpeg error: %s", errTail) + } + return fmt.Errorf("ffmpeg failed: %w", waitErr) + } + + var outSize int64 + if st, err := os.Stat(built.OutputPath); err == nil { + outSize = st.Size() + } + if onProgress != nil { + onProgress(Progress{ + Percent: 100, + OutTimeSec: built.ExpectedDuration, + TotalSec: built.ExpectedDuration, + TotalSize: outSize, + OutputBytes: outSize, + OutputPath: built.OutputPath, + Done: true, + }) + } + return nil +} + +type limitedBuffer struct { + buf *bytes.Buffer + max int +} + +func (l *limitedBuffer) Write(p []byte) (int, error) { + if l.buf.Len()+len(p) > l.max { + // keep latest tail + excess := l.buf.Len() + len(p) - l.max + if excess >= l.buf.Len() { + l.buf.Reset() + } else { + b := l.buf.Bytes()[excess:] + l.buf.Reset() + l.buf.Write(b) + } + } + return l.buf.Write(p) +} diff --git a/internal/media/bin.go b/internal/media/bin.go new file mode 100644 index 0000000..fd87f1b --- /dev/null +++ b/internal/media/bin.go @@ -0,0 +1,84 @@ +package media + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" +) + +type Binaries struct { + FFmpeg string `json:"ffmpeg"` + FFprobe string `json:"ffprobe"` + Source string `json:"source"` // "custom", "bundled", "path", "winget", "missing" +} + +func FindBinaries(customFFmpeg, customFFprobe string) Binaries { + exeName := func(base string) string { + if runtime.GOOS == "windows" { + return base + ".exe" + } + return base + } + + ff := findSingle(exeName("ffmpeg"), customFFmpeg) + fp := findSingle(exeName("ffprobe"), customFFprobe) + + source := "missing" + if ff.path != "" && fp.path != "" { + source = ff.source + } else if ff.path != "" || fp.path != "" { + if ff.path != "" { + source = ff.source + } else { + source = fp.source + } + } + + return Binaries{ + FFmpeg: ff.path, + FFprobe: fp.path, + Source: source, + } +} + +type foundBin struct { + path string + source string +} + +func findSingle(exeName, custom string) foundBin { + if custom != "" { + if st, err := os.Stat(custom); err == nil && !st.IsDir() { + return foundBin{path: custom, source: "custom"} + } + } + + if exePath, err := os.Executable(); err == nil { + dir := filepath.Dir(exePath) + candidates := []string{ + filepath.Join(dir, "bin", exeName), + filepath.Join(dir, exeName), + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return foundBin{path: c, source: "bundled"} + } + } + } + + if p, err := exec.LookPath(exeName); err == nil { + return foundBin{path: p, source: "path"} + } + + if runtime.GOOS == "windows" { + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + wg := filepath.Join(localAppData, "Microsoft", "WinGet", "Links", exeName) + if st, err := os.Stat(wg); err == nil && !st.IsDir() { + return foundBin{path: wg, source: "winget"} + } + } + } + + return foundBin{} +} diff --git a/internal/media/exec_other.go b/internal/media/exec_other.go new file mode 100644 index 0000000..991d4b4 --- /dev/null +++ b/internal/media/exec_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package media + +import "os/exec" + +func hideWindow(_ *exec.Cmd) {} + +func HideCmdWindow(_ *exec.Cmd) {} diff --git a/internal/media/exec_windows.go b/internal/media/exec_windows.go new file mode 100644 index 0000000..120c1a2 --- /dev/null +++ b/internal/media/exec_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package media + +import ( + "os/exec" + "syscall" +) + +func hideWindow(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} + +func HideCmdWindow(cmd *exec.Cmd) { + hideWindow(cmd) +} diff --git a/internal/media/probe.go b/internal/media/probe.go new file mode 100644 index 0000000..fc1d4c6 --- /dev/null +++ b/internal/media/probe.go @@ -0,0 +1,220 @@ +package media + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type AudioStream struct { + Index int `json:"index"` // global stream index + AudioOrder int `json:"audioOrder"` // 0-based audio track index (0:a:) + Codec string `json:"codec"` + Channels int `json:"channels"` + ChannelLayout string `json:"channelLayout"` + SampleRate string `json:"sampleRate"` + Language string `json:"language"` + Title string `json:"title"` +} + +type VideoInfo struct { + Path string `json:"path"` + FileName string `json:"fileName"` + Extension string `json:"extension"` + Container string `json:"container"` + SizeBytes int64 `json:"sizeBytes"` + Duration float64 `json:"duration"` + BitRate int64 `json:"bitRate"` + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` + VideoCodec string `json:"videoCodec"` + PixFmt string `json:"pixFmt"` + AudioStreams []AudioStream `json:"audioStreams"` + BrowserNative bool `json:"browserNative"` +} + +type rawFFprobe struct { + Format struct { + Filename string `json:"filename"` + FormatName string `json:"format_name"` + Duration string `json:"duration"` + Size string `json:"size"` + BitRate string `json:"bit_rate"` + } `json:"format"` + Streams []struct { + Index int `json:"index"` + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width"` + Height int `json:"height"` + PixFmt string `json:"pix_fmt"` + AvgFrameRate string `json:"avg_frame_rate"` + RFrameRate string `json:"r_frame_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + SampleRate string `json:"sample_rate"` + Duration string `json:"duration"` + Tags map[string]string `json:"tags"` + } `json:"streams"` +} + +func Probe(ffprobeBin, inputPath string) (*VideoInfo, error) { + if ffprobeBin == "" { + return nil, fmt.Errorf("ffprobe binary not found") + } + st, err := os.Stat(inputPath) + if err != nil { + return nil, fmt.Errorf("cannot access file: %w", err) + } + if st.IsDir() { + return nil, fmt.Errorf("path is a directory") + } + + args := []string{ + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + inputPath, + } + cmd := exec.Command(ffprobeBin, args...) + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffprobe failed: %w", err) + } + + info, err := ParseProbeJSON(out, inputPath, st.Size()) + if err != nil { + return nil, err + } + return info, nil +} + +func ParseProbeJSON(data []byte, inputPath string, fileSize int64) (*VideoInfo, error) { + var raw rawFFprobe + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("invalid ffprobe json: %w", err) + } + + info := &VideoInfo{ + Path: inputPath, + FileName: filepath.Base(inputPath), + Extension: strings.ToLower(filepath.Ext(inputPath)), + Container: raw.Format.FormatName, + SizeBytes: fileSize, + } + + if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil && d > 0 { + info.Duration = d + } + if sz, err := strconv.ParseInt(raw.Format.Size, 10, 64); err == nil && sz > 0 { + info.SizeBytes = sz + } + if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil && br > 0 { + info.BitRate = br + } + + audioOrder := 0 + hasVideo := false + for _, s := range raw.Streams { + switch s.CodecType { + case "video": + if !hasVideo { + hasVideo = true + info.Width = s.Width + info.Height = s.Height + info.VideoCodec = strings.ToLower(s.CodecName) + info.PixFmt = s.PixFmt + info.FPS = parseRationalFPS(s.AvgFrameRate) + if info.FPS <= 0 { + info.FPS = parseRationalFPS(s.RFrameRate) + } + if info.Duration <= 0 { + if d, err := strconv.ParseFloat(s.Duration, 64); err == nil && d > 0 { + info.Duration = d + } + } + } + case "audio": + lang := "" + title := "" + for k, v := range s.Tags { + lk := strings.ToLower(k) + if lk == "language" { + lang = v + } else if lk == "title" { + title = v + } + } + info.AudioStreams = append(info.AudioStreams, AudioStream{ + Index: s.Index, + AudioOrder: audioOrder, + Codec: strings.ToLower(s.CodecName), + Channels: s.Channels, + ChannelLayout: s.ChannelLayout, + SampleRate: s.SampleRate, + Language: lang, + Title: title, + }) + audioOrder++ + } + } + + if !hasVideo { + return nil, fmt.Errorf("no video stream found in %s", filepath.Base(inputPath)) + } + + info.BrowserNative = isBrowserNative(info) + return info, nil +} + +func parseRationalFPS(expr string) float64 { + expr = strings.TrimSpace(expr) + if expr == "" || expr == "0/0" { + return 0 + } + parts := strings.Split(expr, "/") + if len(parts) == 1 { + v, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0 + } + return v + } + if len(parts) == 2 { + num, err1 := strconv.ParseFloat(parts[0], 64) + den, err2 := strconv.ParseFloat(parts[1], 64) + if err1 != nil || err2 != nil || den == 0 { + return 0 + } + return num / den + } + return 0 +} + +func isBrowserNative(info *VideoInfo) bool { + extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm" + if !extOK { + return false + } + vOK := info.VideoCodec == "h264" || info.VideoCodec == "vp8" || info.VideoCodec == "vp9" || info.VideoCodec == "av1" + if !vOK { + return false + } + // If multiple audio tracks exist, browser plays only track 0; if user wants to preview natively it's fine, + // we still treat single-compatible audio as native. + for _, a := range info.AudioStreams { + switch a.Codec { + case "aac", "mp3", "opus", "vorbis", "flac": + default: + return false + } + } + return true +} diff --git a/internal/media/probe_test.go b/internal/media/probe_test.go new file mode 100644 index 0000000..815102e --- /dev/null +++ b/internal/media/probe_test.go @@ -0,0 +1,96 @@ +package media + +import ( + "testing" +) + +func TestParseProbeJSON_MultiAudio(t *testing.T) { + sample := `{ + "format": { + "filename": "clip.mp4", + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "duration": "125.500000", + "size": "104857600", + "bit_rate": "6683824" + }, + "streams": [ + { + "index": 0, + "codec_type": "video", + "codec_name": "h264", + "width": 1920, + "height": 1080, + "pix_fmt": "yuv420p", + "avg_frame_rate": "60/1", + "r_frame_rate": "60/1" + }, + { + "index": 1, + "codec_type": "audio", + "codec_name": "aac", + "channels": 2, + "channel_layout": "stereo", + "sample_rate": "48000", + "tags": {"title": "Desktop Audio"} + }, + { + "index": 2, + "codec_type": "audio", + "codec_name": "aac", + "channels": 1, + "channel_layout": "mono", + "sample_rate": "48000", + "tags": {"title": "Microphone", "language": "rus"} + } + ] + }` + + info, err := ParseProbeJSON([]byte(sample), `C:\clips\clip.mp4`, 123) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Width != 1920 || info.Height != 1080 { + t.Fatalf("unexpected resolution: %dx%d", info.Width, info.Height) + } + if info.FPS != 60 { + t.Fatalf("expected 60 fps, got %v", info.FPS) + } + if info.Duration != 125.5 { + t.Fatalf("expected duration 125.5, got %v", info.Duration) + } + if len(info.AudioStreams) != 2 { + t.Fatalf("expected 2 audio streams, got %d", len(info.AudioStreams)) + } + if info.AudioStreams[1].AudioOrder != 1 || info.AudioStreams[1].Title != "Microphone" || info.AudioStreams[1].Language != "rus" { + t.Fatalf("unexpected audio stream 1: %+v", info.AudioStreams[1]) + } + if !info.BrowserNative { + t.Fatalf("expected mp4 h264+aac to be browser native") + } +} + +func TestParseProbeJSON_NoVideo(t *testing.T) { + sample := `{"format":{"duration":"10"},"streams":[{"index":0,"codec_type":"audio","codec_name":"mp3"}]}` + if _, err := ParseProbeJSON([]byte(sample), "audio.mp3", 100); err == nil { + t.Fatalf("expected error when no video stream exists") + } +} + +func TestParseRationalFPS(t *testing.T) { + cases := []struct { + in string + want float64 + }{ + {"60/1", 60}, + {"30000/1001", 30000.0 / 1001.0}, + {"0/0", 0}, + {"", 0}, + {"24", 24}, + } + for _, tc := range cases { + got := parseRationalFPS(tc.in) + if got != tc.want { + t.Fatalf("parseRationalFPS(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/internal/preview/server.go b/internal/preview/server.go new file mode 100644 index 0000000..1ed4c0a --- /dev/null +++ b/internal/preview/server.go @@ -0,0 +1,300 @@ +package preview + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "convert/internal/media" +) + +type Server struct { + addr string + httpSrv *http.Server + ffmpegBin string + mu sync.RWMutex + files map[string]registeredFile + proxyLocks map[string]*sync.Mutex + proxyCache map[string]string + thumbCache map[string][]byte + tempFiles []string +} + +type registeredFile struct { + info *media.VideoInfo +} + +func Start(ffmpegBin string) (*Server, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + s := &Server{ + addr: ln.Addr().String(), + ffmpegBin: ffmpegBin, + files: make(map[string]registeredFile), + proxyLocks: make(map[string]*sync.Mutex), + proxyCache: make(map[string]string), + thumbCache: make(map[string][]byte), + } + + mux := http.NewServeMux() + mux.HandleFunc("/video", s.handleVideo) + mux.HandleFunc("/proxy", s.handleProxy) + mux.HandleFunc("/thumb", s.handleThumb) + + s.httpSrv = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + _ = s.httpSrv.Serve(ln) + }() + + return s, nil +} + +func (s *Server) SetFFmpeg(bin string) { + s.mu.Lock() + defer s.mu.Unlock() + s.ffmpegBin = bin +} + +func (s *Server) BaseURL() string { + return "http://" + s.addr +} + +func (s *Server) Register(info *media.VideoInfo) string { + sum := sha1.Sum([]byte(info.Path)) + id := hex.EncodeToString(sum[:8]) + + s.mu.Lock() + s.files[id] = registeredFile{info: info} + s.mu.Unlock() + return id +} + +func (s *Server) Close() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if s.httpSrv != nil { + _ = s.httpSrv.Shutdown(ctx) + } + s.mu.Lock() + for _, p := range s.tempFiles { + _ = os.Remove(p) + } + s.tempFiles = nil + s.mu.Unlock() +} + +func (s *Server) getFile(id string) (registeredFile, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + rf, ok := s.files[id] + return rf, ok +} + +func (s *Server) handleVideo(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + rf, ok := s.getFile(id) + if !ok { + http.NotFound(w, r) + return + } + f, err := os.Open(rf.info.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + + st, err := f.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + http.ServeContent(w, r, rf.info.FileName, st.ModTime(), f) +} + +func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + merge := r.URL.Query().Get("merge") == "1" || r.URL.Query().Get("merge") == "true" + rf, ok := s.getFile(id) + if !ok { + http.NotFound(w, r) + return + } + + s.mu.RLock() + ffmpegBin := s.ffmpegBin + s.mu.RUnlock() + if ffmpegBin == "" { + http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable) + return + } + + cacheKey := fmt.Sprintf("%s_m%v", id, merge) + s.mu.Lock() + lk, exists := s.proxyLocks[cacheKey] + if !exists { + lk = &sync.Mutex{} + s.proxyLocks[cacheKey] = lk + } + s.mu.Unlock() + + lk.Lock() + defer lk.Unlock() + + s.mu.RLock() + proxyPath := s.proxyCache[cacheKey] + s.mu.RUnlock() + + if proxyPath == "" { + proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey)) + if err := s.buildProxy(r.Context(), ffmpegBin, rf.info, merge, proxyPath); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.mu.Lock() + s.proxyCache[cacheKey] = proxyPath + s.tempFiles = append(s.tempFiles, proxyPath) + s.mu.Unlock() + } + + f, err := os.Open(proxyPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + http.ServeContent(w, r, "preview.mp4", st.ModTime(), f) +} + +func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.VideoInfo, merge bool, outPath string) error { + args := []string{"-hide_banner", "-loglevel", "error", "-y", "-i", info.Path} + + numAudio := len(info.AudioStreams) + if numAudio == 0 { + args = append(args, "-map", "0:v:0", "-an") + } else if merge && numAudio > 1 { + var in strings.Builder + for i := 0; i < numAudio; i++ { + in.WriteString(fmt.Sprintf("[0:a:%d]", i)) + } + args = append(args, + "-filter_complex", fmt.Sprintf("%samix=inputs=%d:normalize=0[a]", in.String(), numAudio), + "-map", "0:v:0", "-map", "[a]", + "-c:a", "aac", "-b:a", "128k", + ) + } else { + args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k") + } + + if info.VideoCodec == "h264" { + // Remux video stream instantaneously when codec is already H.264 + args = append(args, "-c:v", "copy") + } else { + args = append(args, + "-c:v", "libx264", + "-preset", "ultrafast", + "-crf", "26", + "-pix_fmt", "yuv420p", + "-vf", "scale='min(1280,iw)':-2", + ) + } + args = append(args, "-movflags", "+faststart", outPath) + + cmd := exec.CommandContext(ctx, ffmpegBin, args...) + media.HideCmdWindow(cmd) + var errBuf bytes.Buffer + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + _ = os.Remove(outPath) + return fmt.Errorf("preview proxy error: %v (%s)", err, strings.TrimSpace(errBuf.String())) + } + return nil +} + +func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + tStr := r.URL.Query().Get("t") + rf, ok := s.getFile(id) + if !ok { + http.NotFound(w, r) + return + } + tSec, err := strconv.ParseFloat(tStr, 64) + if err != nil || tSec < 0 { + tSec = 0 + } + if rf.info.Duration > 0 && tSec >= rf.info.Duration { + tSec = rf.info.Duration - 0.1 + if tSec < 0 { + tSec = 0 + } + } + + key := fmt.Sprintf("%s_%.2f", id, tSec) + s.mu.RLock() + cached, found := s.thumbCache[key] + ffmpegBin := s.ffmpegBin + s.mu.RUnlock() + + if !found { + if ffmpegBin == "" { + http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable) + return + } + cmd := exec.CommandContext(r.Context(), ffmpegBin, + "-hide_banner", "-loglevel", "error", + "-ss", strconv.FormatFloat(tSec, 'f', 3, 64), + "-i", rf.info.Path, + "-frames:v", "1", + "-vf", "scale=200:-1", + "-q:v", "6", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "pipe:1", + ) + media.HideCmdWindow(cmd) + data, err := cmd.Output() + if err != nil || len(data) == 0 { + http.Error(w, "thumbnail generation failed", http.StatusInternalServerError) + return + } + s.mu.Lock() + if len(s.thumbCache) > 200 { + // simple cap reset + s.thumbCache = make(map[string][]byte) + } + s.thumbCache[key] = data + s.mu.Unlock() + cached = data + } + + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = w.Write(cached) +} diff --git a/internal/preview/server_test.go b/internal/preview/server_test.go new file mode 100644 index 0000000..a5087d5 --- /dev/null +++ b/internal/preview/server_test.go @@ -0,0 +1,87 @@ +package preview + +import ( + "io" + "net/http" + "os/exec" + "path/filepath" + "testing" + + "convert/internal/media" +) + +func TestPreviewServer_RangeThumbAndProxy(t *testing.T) { + bins := media.FindBinaries("", "") + if bins.FFmpeg == "" || bins.FFprobe == "" { + t.Skip("ffmpeg/ffprobe not installed") + } + + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "clip.mkv") + gen := exec.Command(bins.FFmpeg, + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=2:size=320x180:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440:duration=2", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-y", src, + ) + if out, err := gen.CombinedOutput(); err != nil { + t.Fatalf("failed generating sample mkv: %v (%s)", err, string(out)) + } + + info, err := media.Probe(bins.FFprobe, src) + if err != nil { + t.Fatalf("probe failed: %v", err) + } + + srv, err := Start(bins.FFmpeg) + if err != nil { + t.Fatalf("preview server start failed: %v", err) + } + defer srv.Close() + + id := srv.Register(info) + + // 1. Test Range request on /video + req, _ := http.NewRequest("GET", srv.BaseURL()+"/video?id="+id, nil) + req.Header.Set("Range", "bytes=0-127") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /video failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusPartialContent { + t.Fatalf("expected 206 Partial Content, got %d", resp.StatusCode) + } + + // 2. Test /thumb JPEG generation + tResp, err := http.Get(srv.BaseURL() + "/thumb?id=" + id + "&t=0.5") + if err != nil { + t.Fatalf("GET /thumb failed: %v", err) + } + tBody, _ := io.ReadAll(tResp.Body) + tResp.Body.Close() + if tResp.StatusCode != http.StatusOK || len(tBody) < 100 || tBody[0] != 0xFF || tBody[1] != 0xD8 { + t.Fatalf("expected valid JPEG thumbnail, status=%d len=%d", tResp.StatusCode, len(tBody)) + } + + // 3. Test /proxy MKV -> MP4 remux + pResp, err := http.Get(srv.BaseURL() + "/proxy?id=" + id + "&merge=0") + if err != nil { + t.Fatalf("GET /proxy failed: %v", err) + } + pBody, _ := io.ReadAll(pResp.Body) + pResp.Body.Close() + if pResp.StatusCode != http.StatusOK || len(pBody) < 500 { + t.Fatalf("expected valid MP4 proxy, status=%d len=%d", pResp.StatusCode, len(pBody)) + } + + // 4. Test unknown ID returns 404 + nfResp, err := http.Get(srv.BaseURL() + "/video?id=nonexistent") + if err != nil { + t.Fatalf("GET /video 404 failed: %v", err) + } + nfResp.Body.Close() + if nfResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", nfResp.StatusCode) + } +} diff --git a/main.go b/main.go index c2f970e..2caa323 100644 --- a/main.go +++ b/main.go @@ -1,187 +1,68 @@ package main import ( + "embed" "flag" - "fmt" "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" ) -const DEFAULT_PRESET = "slow" -const DEFAULT_CRF = "20" +// Version is injected at build time via -ldflags "-X main.Version=..." +var Version = "2.0.0-dev" -func scanArgFromConsole(text string, v *string) error { - fmt.Print(text) - if _, err := fmt.Scanln(v); err != nil && err.Error() == "EOF" { - return err +//go:embed all:frontend/dist +var assets embed.FS + +func parseInitialFile(args []string) string { + fs := flag.NewFlagSet("convert", flag.ContinueOnError) + var input string + var debug bool + fs.StringVar(&input, "i", "", "input video path") + fs.BoolVar(&debug, "debug", false, "debug flag (ignored in GUI)") + _ = fs.Parse(args) + if input != "" { + return input } - return nil -} - -func pressEnterToExit(code int) { - fmt.Print("\npress enter to exit... ") - _, _ = fmt.Scanln() - os.Exit(code) + if fs.NArg() > 0 { + return fs.Arg(0) + } + return "" } func main() { - var err error - var debug bool - var vfArgs []string - var audioFilters []string - var videoFilters []string - var inputVideo, ss, to, an, crf, preset, mergeAudioStreams, speedUp string + initialFile := parseInitialFile(os.Args[1:]) + app := NewApp(initialFile) - ffmpegExecutable := "ffmpeg" - ffmpegArgs := append(make([]string, 0, 30), "-hide_banner") - inputArgs := []string{ - "-hwaccel", "nvdec", - "-threads", "1", - } - postArgs := []string{ - "-c:v", "h264", - "-profile:v", "high", - "-pix_fmt", "yuv420p", - "-tune", "film", - "-movflags", "+faststart", - "-r", "60", - } - - flag.StringVar(&inputVideo, "i", "", "input video path") - flag.BoolVar(&debug, "debug", false, "debug mode (default params)") - flag.Parse() - - // check video exists - if _, err := os.Stat(inputVideo); err != nil { - fmt.Println(err) - pressEnterToExit(1) - } - - stat, err := ffprobe(inputVideo) + err := wails.Run(&options.App{ + Title: "ConvertGUI", + Width: 1240, + Height: 840, + MinWidth: 980, + MinHeight: 680, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 13, G: 17, B: 23, A: 1}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + DragAndDrop: &options.DragAndDrop{ + EnableFileDrop: true, + DisableWebViewDrop: false, + }, + Windows: &windows.Options{ + WebviewIsTransparent: false, + WindowIsTranslucent: false, + Theme: windows.Dark, + }, + Bind: []interface{}{ + app, + }, + }) if err != nil { - fmt.Println(err) - return - } - - if !debug { - err = func() error { - if err = scanArgFromConsole("ss > ", &ss); err != nil { - return err - } - if err = scanArgFromConsole("to > ", &to); err != nil { - return err - } - if err = scanArgFromConsole("an > ", &an); err != nil { - return err - } - if err = scanArgFromConsole(fmt.Sprintf("crf [%v] > ", DEFAULT_CRF), &crf); err != nil { - return err - } - if err = scanArgFromConsole("speed up > ", &speedUp); err != nil { - return err - } - if err = scanArgFromConsole(fmt.Sprintf("preset [%v] > ", DEFAULT_PRESET), &preset); err != nil { - return err - } - if err = scanArgFromConsole("merge audio streams > ", &mergeAudioStreams); err != nil { - return err - } - return nil - }() - if err != nil { - os.Exit(0) - } - } - - for _, v := range []*string{&ss, &to, &an, &crf, &preset, &mergeAudioStreams, &speedUp} { - *v = strings.ToLower(strings.TrimSpace(*v)) - } - - if len(ss) != 0 { - inputArgs = append(inputArgs, "-ss", ss) - } - - if len(to) != 0 { - inputArgs = append(inputArgs, "-to", to) - } - - if len(an) != 0 { - postArgs = append(postArgs, "-an") - } else { - audioFilters = append(audioFilters, "amix=inputs=2:normalize=0") - } - - if len(crf) == 0 { - crf = DEFAULT_CRF - } - - if len(preset) == 0 { - preset = DEFAULT_PRESET - } - - postArgs = append(postArgs, "-preset", preset) - postArgs = append(postArgs, "-crf", crf) - - // speed up video and audio(?) - if len(speedUp) != 0 { - videoFilters = append(videoFilters, fmt.Sprintf("setpts=1/%s*PTS", speedUp)) - if len(an) == 0 { - audioFilters = append(audioFilters, fmt.Sprintf("atempo=%s", speedUp)) - } - } - - if len(videoFilters) != 0 { - vfArgs = append(vfArgs, "[0:v:0]"+strings.Join(videoFilters, ",")+"[v]") - postArgs = append(postArgs, "-map", "[v]") - } else { - postArgs = append(postArgs, "-map", "0:v:0") - } - - if len(audioFilters) != 0 { - vfArgs = append(vfArgs, "[0:a]"+strings.Join(audioFilters, ",")+"[a]") - postArgs = append(postArgs, "-map", "[a]") - } else { - postArgs = append(postArgs, "-map", "0:a") - } - - // merge filter complex args - if len(vfArgs) != 0 { - postArgs = append(postArgs, "-filter_complex", strings.Join(vfArgs, ";")) - } - - // out file name - outDir, _ := filepath.Split(inputVideo) - outFile := filepath.Join(outDir, strconv.FormatInt(time.Now().Unix(), 10)+".mp4") - - // merge args to one slice - ffmpegArgs = append(ffmpegArgs, inputArgs...) - ffmpegArgs = append(ffmpegArgs, "-i", inputVideo) - ffmpegArgs = append(ffmpegArgs, postArgs...) - ffmpegArgs = append(ffmpegArgs, outFile) - - fmt.Printf(" -running %v: %v - -", ffmpegExecutable, strings.Join(ffmpegArgs, " ")) - - proc := exec.Command(ffmpegExecutable, ffmpegArgs...) - proc.Stderr = os.Stderr - err = proc.Start() - if err != nil { - fmt.Println(err) - pressEnterToExit(1) - } - err = proc.Wait() - if err != nil { - fmt.Println(err) - pressEnterToExit(1) - } - if proc.ProcessState.ExitCode() != 0 { - fmt.Println(err) - pressEnterToExit(1) + println("Error:", err.Error()) } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c8d07e1 --- /dev/null +++ b/main_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +func TestParseInitialFile(t *testing.T) { + if got := parseInitialFile([]string{"-i", `C:\clips\demo.mp4`}); got != `C:\clips\demo.mp4` { + t.Fatalf("expected -i path, got %q", got) + } + if got := parseInitialFile([]string{"--debug", "-i", `E:\vid.mkv`}); got != `E:\vid.mkv` { + t.Fatalf("expected -i path with --debug, got %q", got) + } + if got := parseInitialFile([]string{`D:\drop.mov`}); got != `D:\drop.mov` { + t.Fatalf("expected positional path, got %q", got) + } + if got := parseInitialFile(nil); got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..a3fe177 --- /dev/null +++ b/wails.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "ConvertGUI", + "outputfilename": "convert", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "m4xx1m" + }, + "info": { + "companyName": "m4xx1m", + "productName": "ConvertGUI", + "productVersion": "2.0.0", + "copyright": "MIT", + "comments": "Fast FFmpeg GUI Video Converter with Preview and Trim" + } +}