package video import ( "bytes" "context" "encoding/json" "fmt" "os/exec" "strconv" "strings" ) // MaxLongSide is the maximum long side of a prepared playback variant. Sources // larger than this are scaled down; smaller sources are never upscaled. const MaxLongSide = 1920 // Info describes the media facts ffprobe reports for a video file. type Info struct { DurationMS int64 Width int Height int Rotation int VideoCodec string PixelFormat string Profile string AudioCodec string Container string } type ffprobeStream struct { CodecType string `json:"codec_type"` CodecName string `json:"codec_name"` Profile string `json:"profile"` PixFmt string `json:"pix_fmt"` Width int `json:"width"` Height int `json:"height"` Tags map[string]string `json:"tags"` SideDataList []struct { Rotation float64 `json:"rotation"` } `json:"side_data_list"` } type ffprobeOutput struct { Streams []ffprobeStream `json:"streams"` Format struct { FormatName string `json:"format_name"` Duration string `json:"duration"` } `json:"format"` } // Probe runs ffprobe and returns the parsed media facts. func Probe(ctx context.Context, ffprobePath, path string) (*Info, error) { cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path) var out, errb bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffprobe: %w: %s", err, strings.TrimSpace(errb.String())) } var parsed ffprobeOutput if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { return nil, fmt.Errorf("parse ffprobe output: %w", err) } info := &Info{Container: parsed.Format.FormatName} for _, st := range parsed.Streams { switch st.CodecType { case "video": if info.VideoCodec == "" { info.VideoCodec = st.CodecName info.Profile = st.Profile info.PixelFormat = st.PixFmt info.Width = st.Width info.Height = st.Height info.Rotation = rotationOf(st.SideDataList, st.Tags) } case "audio": if info.AudioCodec == "" { info.AudioCodec = st.CodecName } } } info.DurationMS = durationMS(parsed.Format.Duration) return info, nil } func durationMS(seconds string) int64 { f, err := strconv.ParseFloat(strings.TrimSpace(seconds), 64) if err != nil || f < 0 { return 0 } return int64(f*1000 + 0.5) } func rotationOf(sd []struct { Rotation float64 `json:"rotation"` }, tags map[string]string) int { for _, s := range sd { if s.Rotation != 0 { return normalizeDegrees(int(s.Rotation)) } } if tags != nil { if v, ok := tags["rotate"]; ok { if n, err := strconv.Atoi(v); err == nil { return normalizeDegrees(n) } } } return 0 } func normalizeDegrees(n int) int { r := n % 360 if r < 0 { r += 360 } return r } var allowedProfiles = map[string]bool{ "baseline": true, "constrained baseline": true, "main": true, "high": true, } // CanRemux reports whether the source can be copied into a web-friendly mp4 // unchanged. It requires h264/yuv420p with a browser-supported profile, aac or // no audio, a remuxable container, and a resolution within the cap. func (i *Info) CanRemux(maxLongSide int) bool { if i == nil || i.VideoCodec != "h264" { return false } if i.PixelFormat != "yuv420p" { return false } if i.Profile != "" && !allowedProfiles[strings.ToLower(i.Profile)] { return false } if i.AudioCodec != "" && i.AudioCodec != "aac" { return false } if !containerRemuxable(i.Container) { return false } if maxLongSide > 0 && i.LongSide() > maxLongSide { return false } return true } // LongSide returns the larger of width and height. func (i *Info) LongSide() int { if i.Width > i.Height { return i.Width } return i.Height } func containerRemuxable(name string) bool { name = strings.ToLower(name) for _, c := range []string{"mp4", "mov", "matroska", "webm"} { if strings.Contains(name, c) { return true } } return false }