Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f267193e4 | ||
|
|
b68a63ee48 | ||
|
|
99d8e97f83 | ||
|
|
375cff78cb | ||
|
|
478dbff654 | ||
|
|
65387c5dcd |
No files matched your search
@@ -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
@@ -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
|
||||||
Vendored
-19
@@ -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"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
Generated
+1173
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Vendored
+142
@@ -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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
target: 'es2022',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
@@ -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
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
Unicode True
|
||||||
|
!include "MUI2.nsh"
|
||||||
|
|
||||||
|
!ifndef VERSION
|
||||||
|
!define VERSION "2.0.0"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!define APP_NAME "ConvertGUI"
|
||||||
|
!define EXE_NAME "convert.exe"
|
||||||
|
!define UNINSTALL_REG "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
|
||||||
|
|
||||||
|
Name "${APP_NAME} ${VERSION}"
|
||||||
|
OutFile "../dist/ConvertGUI-Setup-${VERSION}.exe"
|
||||||
|
InstallDir "$LOCALAPPDATA\Programs\${APP_NAME}"
|
||||||
|
InstallDirRegKey HKCU "Software\${APP_NAME}" "InstallDir"
|
||||||
|
RequestExecutionLevel user
|
||||||
|
SetCompressor /SOLID lzma
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package media
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func hideWindow(_ *exec.Cmd) {}
|
||||||
|
|
||||||
|
func HideCmdWindow(_ *exec.Cmd) {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.0.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user