feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s

This commit is contained in:
m4xx1m committed 2026-11-12 17:30:44 +03:00
1 parent 02fd1dc817
commit 65387c5dcd
40 files changed
+6084 -266

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package media
import (
"os"
"os/exec"
"path/filepath"
"runtime"
)
type Binaries struct {
FFmpeg string `json:"ffmpeg"`
FFprobe string `json:"ffprobe"`
Source string `json:"source"` // "custom", "bundled", "path", "winget", "missing"
}
func FindBinaries(customFFmpeg, customFFprobe string) Binaries {
exeName := func(base string) string {
if runtime.GOOS == "windows" {
return base + ".exe"
}
return base
}
ff := findSingle(exeName("ffmpeg"), customFFmpeg)
fp := findSingle(exeName("ffprobe"), customFFprobe)
source := "missing"
if ff.path != "" && fp.path != "" {
source = ff.source
} else if ff.path != "" || fp.path != "" {
if ff.path != "" {
source = ff.source
} else {
source = fp.source
}
}
return Binaries{
FFmpeg: ff.path,
FFprobe: fp.path,
Source: source,
}
}
type foundBin struct {
path string
source string
}
func findSingle(exeName, custom string) foundBin {
if custom != "" {
if st, err := os.Stat(custom); err == nil && !st.IsDir() {
return foundBin{path: custom, source: "custom"}
}
}
if exePath, err := os.Executable(); err == nil {
dir := filepath.Dir(exePath)
candidates := []string{
filepath.Join(dir, "bin", exeName),
filepath.Join(dir, exeName),
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return foundBin{path: c, source: "bundled"}
}
}
}
if p, err := exec.LookPath(exeName); err == nil {
return foundBin{path: p, source: "path"}
}
if runtime.GOOS == "windows" {
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
wg := filepath.Join(localAppData, "Microsoft", "WinGet", "Links", exeName)
if st, err := os.Stat(wg); err == nil && !st.IsDir() {
return foundBin{path: wg, source: "winget"}
}
}
}
return foundBin{}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package media
import "os/exec"
func hideWindow(_ *exec.Cmd) {}
func HideCmdWindow(_ *exec.Cmd) {}
+19
View File
@@ -0,0 +1,19 @@
//go:build windows
package media
import (
"os/exec"
"syscall"
)
func hideWindow(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
}
func HideCmdWindow(cmd *exec.Cmd) {
hideWindow(cmd)
}
+220
View File
@@ -0,0 +1,220 @@
package media
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
type AudioStream struct {
Index int `json:"index"` // global stream index
AudioOrder int `json:"audioOrder"` // 0-based audio track index (0:a:<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 isBrowserNative(info *VideoInfo) bool {
extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm"
if !extOK {
return false
}
vOK := info.VideoCodec == "h264" || info.VideoCodec == "vp8" || info.VideoCodec == "vp9" || info.VideoCodec == "av1"
if !vOK {
return false
}
// If multiple audio tracks exist, browser plays only track 0; if user wants to preview natively it's fine,
// we still treat single-compatible audio as native.
for _, a := range info.AudioStreams {
switch a.Codec {
case "aac", "mp3", "opus", "vorbis", "flac":
default:
return false
}
}
return true
}
+96
View File
@@ -0,0 +1,96 @@
package media
import (
"testing"
)
func TestParseProbeJSON_MultiAudio(t *testing.T) {
sample := `{
"format": {
"filename": "clip.mp4",
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "125.500000",
"size": "104857600",
"bit_rate": "6683824"
},
"streams": [
{
"index": 0,
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"pix_fmt": "yuv420p",
"avg_frame_rate": "60/1",
"r_frame_rate": "60/1"
},
{
"index": 1,
"codec_type": "audio",
"codec_name": "aac",
"channels": 2,
"channel_layout": "stereo",
"sample_rate": "48000",
"tags": {"title": "Desktop Audio"}
},
{
"index": 2,
"codec_type": "audio",
"codec_name": "aac",
"channels": 1,
"channel_layout": "mono",
"sample_rate": "48000",
"tags": {"title": "Microphone", "language": "rus"}
}
]
}`
info, err := ParseProbeJSON([]byte(sample), `C:\clips\clip.mp4`, 123)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.Width != 1920 || info.Height != 1080 {
t.Fatalf("unexpected resolution: %dx%d", info.Width, info.Height)
}
if info.FPS != 60 {
t.Fatalf("expected 60 fps, got %v", info.FPS)
}
if info.Duration != 125.5 {
t.Fatalf("expected duration 125.5, got %v", info.Duration)
}
if len(info.AudioStreams) != 2 {
t.Fatalf("expected 2 audio streams, got %d", len(info.AudioStreams))
}
if info.AudioStreams[1].AudioOrder != 1 || info.AudioStreams[1].Title != "Microphone" || info.AudioStreams[1].Language != "rus" {
t.Fatalf("unexpected audio stream 1: %+v", info.AudioStreams[1])
}
if !info.BrowserNative {
t.Fatalf("expected mp4 h264+aac to be browser native")
}
}
func TestParseProbeJSON_NoVideo(t *testing.T) {
sample := `{"format":{"duration":"10"},"streams":[{"index":0,"codec_type":"audio","codec_name":"mp3"}]}`
if _, err := ParseProbeJSON([]byte(sample), "audio.mp3", 100); err == nil {
t.Fatalf("expected error when no video stream exists")
}
}
func TestParseRationalFPS(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"60/1", 60},
{"30000/1001", 30000.0 / 1001.0},
{"0/0", 0},
{"", 0},
{"24", 24},
}
for _, tc := range cases {
got := parseRationalFPS(tc.in)
if got != tc.want {
t.Fatalf("parseRationalFPS(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}