feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s
Build Windows Installer / build (push) Successful in 4m11s
This commit is contained in:
40 files changed
+6084
-266
No files matched your search
@@ -0,0 +1,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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user