229 lines
6.1 KiB
Go
229 lines
6.1 KiB
Go
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
|
|
}
|