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

+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
}