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,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
|
||||
}
|
||||
Reference in new issue
Block a user