7 Commits
Author SHA1 Message Date
m4xx1m e9364cbfe4 chore(release): bump version to v2.1
Build Windows Installer / build (push) Successful in 3m47s
2026-11-12 21:04:08 +03:00
m4xx1m 7f267193e4 fix(installer): smooth in-place updates with auto-close of running app and overwrite
Build Windows Installer / build (push) Canceled after 4m55s
2026-11-12 20:50:37 +03:00
m4xx1m b68a63ee48 style(ui): professional rectangular grayscale theme (Premiere/Resolve style without rounded corners)
Build Windows Installer / build (push) Successful in 24m16s
2026-11-12 20:45:52 +03:00
m4xx1m 99d8e97f83 feat(ui): frameless modern window frame with dark titlebar controls and sleek dark scrollbars
Build Windows Installer / build (push) Successful in 18m51s
2026-11-12 20:40:36 +03:00
m4xx1m 375cff78cb feat(ui): add mouse wheel cycling for select dropdowns, range sliders, and number inputs
Build Windows Installer / build (push) Successful in 10m49s
2026-11-12 20:26:31 +03:00
m4xx1m 478dbff654 fix: Wails file drop, duplicate listeners, trim drag, crop stage, preview cache/pixfmt, NSIS temp quoting
Build Windows Installer / build (push) Successful in 8m46s
2026-11-12 18:04:53 +03:00
m4xx1m 65387c5dcd feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s
2026-11-12 17:30:44 +03:00
42 changed files with 6905 additions and 266 deletions

No files matched your search

+107
View File
@@ -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 }}
+14
View File
@@ -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
-19
View File
@@ -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"]
},
]
}
+25
View File
@@ -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-<version>.exe` (NSIS + bundled static `ffmpeg.exe`/`ffprobe.exe`) and `ConvertGUI-Portable-<version>-win64.zip`
- Publishes release assets on `v*` tags via `akkuman/gitea-release-action@v1`
+315
View File
@@ -0,0 +1,315 @@
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
}
}
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()
}
func (a *App) WindowMinimise() {
if a.ctx != nil {
runtime.WindowMinimise(a.ctx)
}
}
func (a *App) WindowToggleMaximise() {
if a.ctx != nil {
runtime.WindowToggleMaximise(a.ctx)
}
}
func (a *App) WindowClose() {
if a.ctx != nil {
runtime.Quit(a.ctx)
}
}
func (a *App) WindowIsMaximised() bool {
if a.ctx != nil {
return runtime.WindowIsMaximised(a.ctx)
}
return false
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="2.0.0.0" processorArchitecture="amd64" name="ConvertGUI" type="win32"/>
<description>ConvertGUI Video Converter</description>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
</application>
</compatibility>
</assembly>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

-36
View File
@@ -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
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ConvertGUI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1173
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -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 src/wheel.test.ts"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
+100
View File
@@ -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;
+1434
View File
File diff suppressed because it is too large. Load diff
+833
View File
@@ -0,0 +1,833 @@
:root {
/* Premiere / Pro-NLE Grayscale Palette */
--bg: #1c1c1c;
--panel: #252525;
--panel-2: #2b2b2b;
--panel-hover: #343434;
--panel-active: #3e3e3e;
--border: #383838;
--border-light: #484848;
--border-focus: #6e6e6e;
--text: #cccccc;
--text-bright: #f0f0f0;
--muted: #858585;
--dim: #5c5c5c;
/* Restrained Professional Accents */
--accent: #4a82c2;
--accent-hover: #5d93d4;
--accent-active: #3c6ea5;
--accent-glow: rgba(74, 130, 194, 0.22);
--trim-in: #4a82c2;
--trim-out: #8ba7c7;
--trim-band: rgba(74, 130, 194, 0.28);
--green: #4e8e5d;
--green-bg: rgba(78, 142, 93, 0.16);
--red: #a84646;
--red-bg: rgba(168, 70, 70, 0.16);
--amber: #a87d3e;
--amber-bg: rgba(168, 125, 62, 0.16);
--radius: 0px;
--mono: 'Cascadia Mono', 'Consolas', 'Segoe UI Mono', ui-monospace, Menlo, Monaco, monospace;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 12px;
user-select: none;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* Professional Flat Dark Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #181818;
}
::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 0;
border: 1px solid #181818;
}
::-webkit-scrollbar-thumb:hover {
background: #505050;
}
* {
scrollbar-width: thin;
scrollbar-color: #3c3c3c #181818;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
body.maximized #app {
border: none;
box-shadow: none;
}
/* Top Bar / Titlebar */
.topbar {
--wails-draggable: drag;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 0 0 12px;
height: 34px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
user-select: none;
}
.topbar button,
.topbar input,
.topbar select,
.topbar .file-pill,
.win-controls {
--wails-draggable: no-drag;
}
/* Window Controls (Minimize, Maximize, Close) */
.win-controls {
display: flex;
align-items: stretch;
height: 100%;
margin-left: 4px;
--wails-draggable: no-drag;
}
.win-btn {
width: 44px;
height: 100%;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--muted) !important;
display: flex !important;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 !important;
transition: background 0.1s ease, color 0.1s ease;
}
.win-btn:hover {
background: #333333 !important;
color: var(--text-bright) !important;
}
.win-btn.close:hover {
background: #c42b1c !important;
color: #ffffff !important;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.brand-icon {
width: 20px;
height: 20px;
border-radius: 0;
background: #333333;
border: 1px solid var(--border-light);
display: grid;
place-items: center;
font-weight: 700;
color: var(--accent);
font-size: 11px;
}
.brand-title {
font-weight: 700;
font-size: 12px;
letter-spacing: 0.5px;
color: var(--text-bright);
text-transform: uppercase;
}
.badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 0;
background: #202020;
border: 1px solid var(--border);
color: var(--muted);
font-family: var(--mono);
}
.topbar-center {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
justify-content: center;
}
.file-pill {
display: flex;
align-items: center;
gap: 8px;
background: #202020;
border: 1px solid var(--border);
padding: 3px 10px;
border-radius: 0;
max-width: 520px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
}
.file-pill strong {
color: var(--text-bright);
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: 6px;
height: 100%;
}
/* Professional Flat Buttons */
button {
font-family: inherit;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border-light);
background: var(--panel-2);
color: var(--text);
padding: 5px 10px;
border-radius: 0;
cursor: pointer;
transition: all 0.1s ease;
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1.2;
}
button:hover:not(:disabled) {
border-color: var(--border-focus);
background: var(--panel-hover);
color: var(--text-bright);
}
button:active:not(:disabled) {
background: var(--panel-active);
}
button:disabled {
opacity: 0.35;
cursor: not-allowed;
border-color: var(--border);
}
button.primary {
background: var(--accent-active);
border-color: var(--accent);
color: #ffffff;
}
button.primary:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent-hover);
color: #ffffff;
}
button.danger {
background: var(--red-bg);
border-color: var(--red);
color: #e57373;
}
button.danger:hover:not(:disabled) {
background: rgba(168, 70, 70, 0.28);
}
button.pill-active {
background: #383838;
border-color: var(--accent);
color: var(--text-bright);
}
.status-pill {
font-size: 10px;
padding: 3px 8px;
border-radius: 0;
font-family: var(--mono);
}
.status-pill.ok {
background: var(--green-bg);
border-color: var(--green);
color: #98c998;
}
.status-pill.warn {
background: var(--amber-bg);
border-color: var(--amber);
color: #e0b06b;
}
/* Main Workspace Grid */
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 390px;
flex: 1;
min-height: 0;
background: var(--bg);
}
/* Left Stage */
.stage-col {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 10px;
gap: 10px;
}
.player-wrap {
position: relative;
flex: 1;
min-height: 240px;
background: #141414;
border: 1px solid var(--border);
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.video-stage {
position: relative;
display: block;
max-width: 100%;
max-height: 100%;
overflow: hidden;
}
video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #000000;
}
.drop-overlay {
position: absolute;
inset: 10px;
border: 1px dashed var(--border-light);
border-radius: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
color: var(--muted);
background: rgba(20, 20, 20, 0.92);
cursor: pointer;
}
.drop-overlay.drag-over {
border-color: var(--accent);
background: rgba(74, 130, 194, 0.08);
color: var(--text-bright);
}
.drop-overlay h2 {
margin: 0;
font-size: 16px;
color: var(--text-bright);
font-weight: 600;
}
.proxy-banner {
position: absolute;
top: 8px;
left: 8px;
background: rgba(28, 28, 28, 0.92);
border: 1px solid var(--border-focus);
color: var(--text-bright);
padding: 4px 8px;
border-radius: 0;
font-size: 10px;
font-family: var(--mono);
z-index: 5;
}
/* Crop Overlay */
.crop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.crop-box {
position: absolute;
border: 1px solid #ffffff;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.65);
cursor: move;
pointer-events: auto;
}
.crop-handle {
position: absolute;
width: 9px;
height: 9px;
background: #ffffff;
border: 1px solid #000000;
border-radius: 0;
}
.crop-handle.nw { top: -5px; left: -5px; cursor: nwse-resize; }
.crop-handle.ne { top: -5px; right: -5px; cursor: nesw-resize; }
.crop-handle.sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
.crop-handle.se { bottom: -5px; right: -5px; cursor: nwse-resize; }
/* Transport Bar */
.transport {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0;
padding: 6px 10px;
}
.transport-group {
display: flex;
align-items: center;
gap: 5px;
}
.time-readout {
font-family: var(--mono);
font-size: 11px;
color: var(--text-bright);
background: #181818;
padding: 4px 8px;
border-radius: 0;
border: 1px solid var(--border);
}
/* Timeline & Trim */
.timeline-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-track {
position: relative;
height: 52px;
background: #121212;
border: 1px solid var(--border);
border-radius: 0;
overflow: visible;
cursor: pointer;
}
.filmstrip {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(12, 1fr);
overflow: hidden;
border-radius: 0;
}
.filmstrip img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.7;
pointer-events: none;
}
.trim-dim-left,
.trim-dim-right {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
pointer-events: none;
border-radius: 0;
}
.trim-dim-left { left: 0; }
.trim-dim-right { right: 0; }
.trim-selection {
position: absolute;
top: 0;
bottom: 0;
background: var(--trim-band);
border-top: 2px solid var(--trim-in);
border-bottom: 2px solid var(--trim-in);
border-radius: 0;
pointer-events: none;
}
.trim-handle {
position: absolute;
top: -2px;
bottom: -2px;
width: 10px;
transform: translateX(-50%);
cursor: ew-resize;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
}
.trim-handle::before {
content: '';
width: 5px;
height: 100%;
border-radius: 0;
}
.trim-handle.start::before {
background: var(--trim-in);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.trim-handle.end::before {
background: var(--trim-out);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.playhead {
position: absolute;
top: -4px;
bottom: -4px;
width: 1px;
background: #ffffff;
transform: translateX(-50%);
pointer-events: none;
z-index: 5;
}
.playhead::before {
content: '';
position: absolute;
top: -2px;
left: -4px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #4a82c2;
}
.trim-inputs {
display: grid;
grid-template-columns: 1fr 1fr auto auto auto;
gap: 6px;
align-items: end;
}
.field-mini {
display: flex;
flex-direction: column;
gap: 2px;
}
.field-mini label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.stat-chip {
background: #1a1a1a;
border: 1px solid var(--border);
border-radius: 0;
padding: 4px 8px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
color: var(--text);
}
/* 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: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 0;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #353535;
padding-bottom: 4px;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.field {
display: flex;
flex-direction: column;
gap: 3px;
}
.field label {
font-size: 10px;
color: var(--muted);
display: flex;
justify-content: space-between;
letter-spacing: 0.3px;
}
input[type='text'],
input[type='number'],
select {
width: 100%;
background: #1c1c1c;
border: 1px solid var(--border-light);
color: var(--text-bright);
padding: 5px 7px;
border-radius: 0;
font-family: inherit;
font-size: 11px;
outline: none;
transition: border-color 0.1s ease;
}
input[type='text']:focus,
input[type='number']:focus,
select:focus {
border-color: var(--border-focus);
}
input.mono {
font-family: var(--mono);
}
/* Premiere-Style Rectangular Slider */
input[type='range'] {
width: 100%;
accent-color: var(--accent);
background: transparent;
cursor: pointer;
height: 16px;
}
.pill-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.pill-btn {
padding: 4px 8px;
font-size: 10px;
border-radius: 0;
background: #242424;
border: 1px solid var(--border-light);
color: var(--text);
}
.pill-btn:hover {
background: #333333;
color: var(--text-bright);
}
.pill-btn.pill-active {
background: #3c3c3c;
border-color: var(--border-focus);
color: #ffffff;
}
.track-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.track-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
background: #202020;
border: 1px solid var(--border);
padding: 4px 7px;
border-radius: 0;
}
.input-row {
display: flex;
gap: 5px;
}
.input-row input {
flex: 1;
}
.cli-box {
background: #141414;
border: 1px solid var(--border);
border-radius: 0;
padding: 6px 8px;
font-family: var(--mono);
font-size: 10px;
color: #9e9e9e;
word-break: break-all;
max-height: 80px;
overflow-y: auto;
user-select: text;
}
/* Sticky Bottom Action */
.action-footer {
padding: 10px;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
gap: 6px;
}
.progress-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-bar-bg {
height: 6px;
background: #141414;
border-radius: 0;
overflow: hidden;
border: 1px solid var(--border);
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: var(--accent);
border-radius: 0;
transition: width 0.15s linear;
}
.progress-meta {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
}
.done-banner {
background: #1c261e;
border: 1px solid var(--green);
border-radius: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.error-banner {
background: #2b1d1d;
border: 1px solid var(--red);
border-radius: 0;
padding: 7px 8px;
color: #e57373;
font-size: 11px;
word-break: break-word;
user-select: text;
}
.hidden {
display: none !important;
}
+31
View File
@@ -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');
});
});
+94
View File
@@ -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 };
}
+142
View File
@@ -0,0 +1,142 @@
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<AppInfo>;
RefreshBinaries(): Promise<Binaries>;
SaveConfig(cfg: AppConfig): Promise<AppInfo>;
OpenVideoDialog(): Promise<string>;
SaveVideoDialog(defaultPath: string): Promise<string>;
SelectFFmpegDialog(): Promise<AppInfo>;
LoadVideo(path: string): Promise<LoadedVideo>;
PreviewCommand(req: ConvertRequest): Promise<string>;
StartConvert(req: ConvertRequest): Promise<void>;
CancelConvert(): Promise<boolean>;
OpenInExplorer(path: string): Promise<void>;
OpenOutputFile(path: string): Promise<void>;
WindowMinimise(): Promise<void>;
WindowToggleMaximise(): Promise<void>;
WindowClose(): Promise<void>;
WindowIsMaximised(): Promise<boolean>;
};
};
};
runtime?: {
EventsOn(eventName: string, callback: (...args: any[]) => void): () => void;
OnFileDrop?(callback: (x: number, y: number, paths: string[]) => void, useDropTarget?: boolean): void;
OnFileDropOff?(): void;
WindowMinimise?(): void;
WindowToggleMaximise?(): void;
WindowClose?(): void;
WindowIsMaximised?(): Promise<boolean>;
};
}
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { WheelStepper, nextEnabledOptionIndex, stepNumericValue } from './wheel.ts';
describe('wheel helpers', () => {
it('triggers immediately on mouse wheel notch and accumulates touchpad micro-deltas', () => {
const stepper = new WheelStepper(40, 250);
assert.equal(stepper.feed({ deltaY: 100, nowMs: 1000 }), 1);
assert.equal(stepper.feed({ deltaY: -100, nowMs: 1050 }), -1);
// Touchpad micro-events accumulate until threshold
assert.equal(stepper.feed({ deltaY: 15, nowMs: 1100 }), 0);
assert.equal(stepper.feed({ deltaY: 15, nowMs: 1120 }), 0);
assert.equal(stepper.feed({ deltaY: 15, nowMs: 1140 }), 1);
// Reversing direction clears prior partial delta
assert.equal(stepper.feed({ deltaY: 20, nowMs: 1200 }), 0);
assert.equal(stepper.feed({ deltaY: -25, nowMs: 1220 }), 0);
assert.equal(stepper.feed({ deltaY: -20, nowMs: 1240 }), -1);
// Idle reset discards stale delta
assert.equal(stepper.feed({ deltaY: 30, nowMs: 1300 }), 0);
assert.equal(stepper.feed({ deltaY: 20, nowMs: 1700 }), 0);
});
it('skips disabled options and clamps at bounds', () => {
const flags = [false, true, false, false];
assert.equal(nextEnabledOptionIndex(0, flags, 1), 2);
assert.equal(nextEnabledOptionIndex(2, flags, -1), 0);
assert.equal(nextEnabledOptionIndex(0, flags, -1), 0);
assert.equal(nextEnabledOptionIndex(3, flags, 1), 3);
});
it('steps numeric values cleanly without float drift', () => {
assert.equal(stepNumericValue(1.1, 0.05, 0.25, 8, 1), 1.15);
assert.equal(stepNumericValue(0.25, 0.05, 0.25, 8, -1), 0.25);
assert.equal(stepNumericValue(51, 1, 0, 51, 1), 51);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Pure wheel-delta accumulator and control-stepping helpers for mouse-wheel
* switching on select, range, and number inputs.
*/
export interface WheelSample {
deltaY: number;
deltaX?: number;
deltaMode?: number;
nowMs?: number;
}
export class WheelStepper {
private accum = 0;
private lastMs = 0;
private readonly threshold: number;
private readonly resetMs: number;
constructor(threshold = 40, resetMs = 250) {
this.threshold = threshold;
this.resetMs = resetMs;
}
/**
* Feeds a wheel event and returns +1 (down/next), -1 (up/prev), or 0 (not enough delta yet).
*/
feed(sample: WheelSample): 1 | -1 | 0 {
const now = sample.nowMs ?? Date.now();
if (this.lastMs > 0 && now - this.lastMs > this.resetMs) {
this.accum = 0;
}
this.lastMs = now;
const dx = sample.deltaX ?? 0;
let delta = Math.abs(sample.deltaY) >= Math.abs(dx) ? sample.deltaY : dx;
if (delta === 0) return 0;
if (sample.deltaMode === 1) delta *= 33;
else if (sample.deltaMode === 2) delta *= 100;
if (this.accum !== 0 && Math.sign(delta) !== Math.sign(this.accum)) {
this.accum = 0;
}
this.accum += delta;
if (Math.abs(this.accum) < this.threshold) {
return 0;
}
const dir: 1 | -1 = this.accum > 0 ? 1 : -1;
this.accum = 0;
return dir;
}
}
export function nextEnabledOptionIndex(
currentIndex: number,
disabledFlags: readonly boolean[],
dir: 1 | -1
): number {
let idx = currentIndex + dir;
while (idx >= 0 && idx < disabledFlags.length && disabledFlags[idx]) {
idx += dir;
}
if (idx < 0 || idx >= disabledFlags.length) {
return currentIndex;
}
return idx;
}
export function stepNumericValue(
current: number,
step: number,
min: number,
max: number,
dir: 1 | -1
): number {
const safeStep = Number.isFinite(step) && step > 0 ? step : 1;
const raw = current + dir * safeStep;
const clamped = Math.max(min, Math.min(max, raw));
// Round to avoid binary floating-point drift on fractional steps (e.g. 0.05)
return Number(clamped.toFixed(6));
}
function attachWheelStep(
el: HTMLElement,
isDisabled: () => boolean,
onStep: (dir: 1 | -1) => void
) {
const stepper = new WheelStepper();
el.addEventListener(
'wheel',
(e: WheelEvent) => {
if (isDisabled()) return;
e.preventDefault();
e.stopPropagation();
const dir = stepper.feed({
deltaY: e.deltaY,
deltaX: e.deltaX,
deltaMode: e.deltaMode,
});
if (dir !== 0) {
onStep(dir);
}
},
{ passive: false }
);
}
export function bindWheelControls(root: ParentNode = document) {
root.querySelectorAll<HTMLSelectElement>('select').forEach((sel) => {
attachWheelStep(
sel,
() => sel.disabled || sel.options.length <= 1,
(dir) => {
const disabled = Array.from(sel.options, (opt) => opt.disabled);
const nextIdx = nextEnabledOptionIndex(sel.selectedIndex, disabled, dir);
if (nextIdx !== sel.selectedIndex) {
sel.selectedIndex = nextIdx;
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
}
);
});
root.querySelectorAll<HTMLInputElement>('input[type="range"]').forEach((rng) => {
attachWheelStep(
rng,
() => rng.disabled,
(dir) => {
const step = Number(rng.step) || 1;
const min = rng.min !== '' ? Number(rng.min) : 0;
const max = rng.max !== '' ? Number(rng.max) : 100;
const cur = Number(rng.value);
// Wheel up (dir === -1) increases slider value; wheel down (dir === 1) decreases it.
const next = stepNumericValue(cur, step, min, max, dir === -1 ? 1 : -1);
if (next !== cur) {
rng.value = String(next);
rng.dispatchEvent(new Event('input', { bubbles: true }));
rng.dispatchEvent(new Event('change', { bubbles: true }));
}
}
);
});
root.querySelectorAll<HTMLInputElement>('input[type="number"]').forEach((inp) => {
attachWheelStep(
inp,
() => inp.disabled || inp.readOnly,
(dir) => {
const step = Number(inp.step) || 1;
const min = inp.min !== '' ? Number(inp.min) : -Infinity;
const max = inp.max !== '' ? Number(inp.max) : Infinity;
const cur = Number(inp.value) || 0;
const next = stepNumericValue(cur, step, min, max, dir === -1 ? 1 : -1);
if (next !== cur) {
inp.value = String(next);
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
}
}
);
});
}
+19
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
},
});
+33 -1
View File
@@ -1,3 +1,35 @@
module convert 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
)
+81
View File
@@ -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=
-39
View File
@@ -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
+156
View File
@@ -0,0 +1,156 @@
Unicode True
!include "MUI2.nsh"
!ifndef VERSION
!define VERSION "2.1.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
SetOverwrite on
!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 $\'$TEMP\wv2setup.exe$\'; Start-Process $\'$TEMP\wv2setup.exe$\' -ArgumentList $\'/silent$\',$\'/install$\' -Wait; Remove-Item $\'$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} "Создать ярлык на Рабочем столе."
LangString MSG_RUNNING ${LANG_ENGLISH} "${APP_NAME} is currently running.$\r$\n$\r$\nClick OK to close it and proceed with the update, or Cancel to abort."
LangString MSG_RUNNING ${LANG_RUSSIAN} "Приложение ${APP_NAME} сейчас запущено.$\r$\n$\r$\nНажмите ОК, чтобы закрыть его и продолжить обновление, или Отмена для выхода."
Function .onInit
check_running:
nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq ${EXE_NAME}" | find /I "${EXE_NAME}"'
Pop $0
StrCmp $0 "0" 0 not_running
MessageBox MB_OKCANCEL|MB_ICONQUESTION $(MSG_RUNNING) IDOK close_it IDCANCEL abort_it
abort_it:
Abort
close_it:
nsExec::ExecToLog 'taskkill /F /IM ${EXE_NAME}'
Sleep 500
Goto check_running
not_running:
FunctionEnd
!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
+84
View File
@@ -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)
}
+42
View File
@@ -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)
}
}
+367
View File
@@ -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 tracks == nil {
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)
}
+278
View File
@@ -0,0 +1,278 @@
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)
}
}
func TestBuildCommand_EmptySelectedTracksMutes(t *testing.T) {
info := &media.VideoInfo{
Path: `C:\videos\clip.mp4`,
Duration: 10,
Width: 1280,
Height: 720,
AudioStreams: []media.AudioStream{
{Index: 1, AudioOrder: 0, Codec: "aac"},
{Index: 2, AudioOrder: 1, Codec: "aac"},
},
}
built, err := BuildCommand(Request{
InputPath: `C:\videos\clip.mp4`,
Encoder: "libx264",
Preset: "ultrafast",
CRF: 23,
Speed: 1.0,
AudioMode: "merge",
SelectedTracks: []int{},
}, info)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
joined := strings.Join(built.Args, " ")
if !strings.Contains(joined, " -an ") {
t.Fatalf("expected -an when SelectedTracks is explicit empty slice, got: %s", joined)
}
}
func TestRunner_NoClobberPreservesExistingOutput(t *testing.T) {
bins := media.FindBinaries("", "")
if bins.FFmpeg == "" {
t.Skip("ffmpeg not installed on host")
}
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "source.mp4")
dst := filepath.Join(tmpDir, "existing.mp4")
gen := exec.Command(bins.FFmpeg,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x180:rate=30",
"-c:v", "libx264", "-preset", "ultrafast", "-y", src,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Fatalf("failed generating test video: %v (%s)", err, string(out))
}
sentinel := []byte("keep-me-intact")
if err := os.WriteFile(dst, sentinel, 0o644); err != nil {
t.Fatalf("write sentinel failed: %v", err)
}
info := &media.VideoInfo{Path: src, Duration: 1, Width: 320, Height: 180}
built, err := BuildCommand(Request{
InputPath: src,
OutputPath: dst,
Encoder: "libx264",
Preset: "ultrafast",
CRF: 24,
Speed: 1.0,
AudioMode: "mute",
Overwrite: false,
}, info)
if err != nil {
t.Fatalf("BuildCommand failed: %v", err)
}
runner := NewRunner()
if err := runner.Run(context.Background(), bins.FFmpeg, built, nil); err == nil {
t.Fatalf("expected error when Overwrite=false and destination exists")
}
got, err := os.ReadFile(dst)
if err != nil || string(got) != string(sentinel) {
t.Fatalf("existing destination file was modified or deleted! err=%v got=%q", err, string(got))
}
}
+109
View File
@@ -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
}
+185
View File
@@ -0,0 +1,185 @@
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}
_, statErr := os.Stat(built.OutputPath)
existedBefore := statErr == nil
hasNoClobber := false
for _, a := range built.Args {
if a == "-n" {
hasNoClobber = true
break
}
}
if existedBefore && hasNoClobber {
return fmt.Errorf("output file already exists: %s", built.OutputPath)
}
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 {
if !(existedBefore && hasNoClobber) {
_ = os.Remove(built.OutputPath)
}
return fmt.Errorf("conversion cancelled")
}
if waitErr != nil {
if !(existedBefore && hasNoClobber) {
_ = 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)
}
+84
View File
@@ -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{}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package media
import "os/exec"
func hideWindow(_ *exec.Cmd) {}
func HideCmdWindow(_ *exec.Cmd) {}
+19
View File
@@ -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)
}
+228
View File
@@ -0,0 +1,228 @@
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:<order>)
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 IsH264BrowserNativePixFmt(pixFmt string) bool {
p := strings.ToLower(strings.TrimSpace(pixFmt))
return p == "" || p == "yuv420p" || p == "yuvj420p"
}
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 info.VideoCodec == "h264" && !IsH264BrowserNativePixFmt(info.PixFmt) {
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
}
+129
View File
@@ -0,0 +1,129 @@
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)
}
}
}
func TestParseProbeJSON_H264PixFmtCompatibility(t *testing.T) {
makeSample := func(pixFmt string) []byte {
return []byte(`{
"format": {"duration": "10.0"},
"streams": [{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1280, "height": 720, "pix_fmt": "` + pixFmt + `"}]
}`)
}
okInfo, err := ParseProbeJSON(makeSample("yuv420p"), "clip.mp4", 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !okInfo.BrowserNative {
t.Fatalf("expected yuv420p mp4 to be browser native")
}
hi10Info, err := ParseProbeJSON(makeSample("yuv420p10le"), "clip10.mp4", 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hi10Info.BrowserNative {
t.Fatalf("expected 10-bit yuv420p10le mp4 NOT to be browser native")
}
fmt422Info, err := ParseProbeJSON(makeSample("yuv422p"), "clip422.mp4", 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fmt422Info.BrowserNative {
t.Fatalf("expected yuv422p mp4 NOT to be browser native")
}
}
+313
View File
@@ -0,0 +1,313 @@
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
ctx context.Context
cancel context.CancelFunc
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
}
ctx, cancel := context.WithCancel(context.Background())
s := &Server{
addr: ln.Addr().String(),
ctx: ctx,
cancel: cancel,
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 {
var modUnix int64
if st, err := os.Stat(info.Path); err == nil {
modUnix = st.ModTime().UnixNano()
}
key := fmt.Sprintf("%s|%d|%d", info.Path, info.SizeBytes, modUnix)
sum := sha1.Sum([]byte(key))
id := hex.EncodeToString(sum[:8])
s.mu.Lock()
s.files[id] = registeredFile{info: info}
s.mu.Unlock()
return id
}
func (s *Server) Close() {
if s.cancel != nil {
s.cancel()
}
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(s.ctx, 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" && media.IsH264BrowserNativePixFmt(info.PixFmt) {
// Remux video stream instantaneously when codec is already browser-native 8-bit 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,trunc(iw/2)*2)':-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)
}
+142
View File
@@ -0,0 +1,142 @@
package preview
import (
"io"
"net/http"
"os"
"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)
}
}
func TestPreviewServer_CacheInvalidationAndOddWidthProxy(t *testing.T) {
bins := media.FindBinaries("", "")
if bins.FFmpeg == "" || bins.FFprobe == "" {
t.Skip("ffmpeg/ffprobe not installed")
}
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "odd.mkv")
// Odd width (321x180) vp8 source forces libx264 transcode in /proxy
gen := exec.Command(bins.FFmpeg,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=1:size=321x180:rate=30",
"-c:v", "libvpx", "-y", src,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Fatalf("failed generating odd-width 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("start failed: %v", err)
}
defer srv.Close()
id1 := srv.Register(info)
// /proxy must succeed even on odd width (321 -> 320)
pResp, err := http.Get(srv.BaseURL() + "/proxy?id=" + id1 + "&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 for odd-width source, status=%d len=%d", pResp.StatusCode, len(pBody))
}
// Overwrite same path with different size -> Register must yield a new ID
if err := os.WriteFile(src, []byte("different-content-size"), 0o644); err != nil {
t.Fatalf("overwrite failed: %v", err)
}
info2 := *info
info2.SizeBytes = 22
id2 := srv.Register(&info2)
if id1 == id2 {
t.Fatalf("expected different videoId after file modification at same path, both were %q", id1)
}
}
+59 -171
View File
@@ -1,187 +1,75 @@
package main package main
import ( import (
"embed"
"flag" "flag"
"fmt"
"os" "os"
"os/exec"
"path/filepath" "github.com/wailsapp/wails/v2"
"strconv" "github.com/wailsapp/wails/v2/pkg/options"
"strings" "github.com/wailsapp/wails/v2/pkg/options/assetserver"
"time" "github.com/wailsapp/wails/v2/pkg/options/windows"
) )
const DEFAULT_PRESET = "slow" // Version is injected at build time via -ldflags "-X main.Version=..."
const DEFAULT_CRF = "20" var Version = "2.1.0-dev"
func scanArgFromConsole(text string, v *string) error { //go:embed all:frontend/dist
fmt.Print(text) var assets embed.FS
if _, err := fmt.Scanln(v); err != nil && err.Error() == "EOF" {
return err 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 if fs.NArg() > 0 {
} return fs.Arg(0)
}
func pressEnterToExit(code int) { return ""
fmt.Print("\npress enter to exit... ")
_, _ = fmt.Scanln()
os.Exit(code)
} }
func main() { func main() {
var err error initialFile := parseInitialFile(os.Args[1:])
var debug bool app := NewApp(initialFile)
var vfArgs []string
var audioFilters []string
var videoFilters []string
var inputVideo, ss, to, an, crf, preset, mergeAudioStreams, speedUp string
ffmpegExecutable := "ffmpeg" err := wails.Run(&options.App{
ffmpegArgs := append(make([]string, 0, 30), "-hide_banner") Title: "ConvertGUI",
inputArgs := []string{ Width: 1240,
"-hwaccel", "nvdec", Height: 840,
"-threads", "1", MinWidth: 980,
} MinHeight: 680,
postArgs := []string{ AssetServer: &assetserver.Options{
"-c:v", "h264", Assets: assets,
"-profile:v", "high", },
"-pix_fmt", "yuv420p", BackgroundColour: &options.RGBA{R: 28, G: 28, B: 28, A: 1},
"-tune", "film", Frameless: true,
"-movflags", "+faststart", OnStartup: app.startup,
"-r", "60", OnShutdown: app.shutdown,
} DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true,
flag.StringVar(&inputVideo, "i", "", "input video path") DisableWebViewDrop: false,
flag.BoolVar(&debug, "debug", false, "debug mode (default params)") },
flag.Parse() Windows: &windows.Options{
WebviewIsTransparent: false,
// check video exists WindowIsTranslucent: false,
if _, err := os.Stat(inputVideo); err != nil { DisableFramelessWindowDecorations: true,
fmt.Println(err) Theme: windows.Dark,
pressEnterToExit(1) CustomTheme: &windows.ThemeSettings{
} DarkModeTitleBar: windows.RGB(37, 37, 37),
DarkModeTitleText: windows.RGB(212, 212, 212),
stat, err := ffprobe(inputVideo) DarkModeBorder: windows.RGB(56, 56, 56),
},
},
Bind: []interface{}{
app,
},
})
if err != nil { if err != nil {
fmt.Println(err) println("Error:", err.Error())
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)
} }
} }
+18
View File
@@ -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)
}
}
+19
View File
@@ -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"
}
}