package media import ( "bytes" "context" "encoding/json" "errors" "math" "os/exec" "strconv" "strings" "hamlab/internal/priv" ) // Track is one stream of a media file, as ffprobe reports it. type Track struct { Index int `json:"index"` Type string `json:"type"` // video | audio Codec string `json:"codec"` Profile string `json:"profile,omitempty"` PixFmt string `json:"pix_fmt,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` FPS float64 `json:"fps,omitempty"` ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR Channels int `json:"channels,omitempty"` AttachedPic bool `json:"attached_pic,omitempty"` Spherical bool `json:"spherical,omitempty"` // an equirectangular 360 picture Rotation int `json:"rotation,omitempty"` // degrees clockwise to turn it to be shown: 0, 90, 180, 270 (a phone held upright) } // Shown is the picture's size as it is shown, turned by its rotation. func (t *Track) Shown() (w, h int) { if t.Rotation == 90 || t.Rotation == 270 { return t.Height, t.Width } return t.Width, t.Height } // Info is what a file holds: the picture to take, the sound to go with it, and the whole. type Info struct { Container string `json:"container"` // "matroska,webm", "mov,mp4,m4a,3gp,3g2,mj2" Duration float64 `json:"duration"` BitRate int64 `json:"bit_rate,omitempty"` // bits per second, whole file Size int64 `json:"size,omitempty"` Video *Track `json:"video,omitempty"` // the first real picture stream; cover art is not it Audio *Track `json:"audio,omitempty"` // the first sound stream } // PQ reports HDR10 (and Dolby Vision's base layer): a picture that can't be shown on an SDR // screen without tone mapping. HLG, the other kind, can, if a little flat. func (i Info) PQ() bool { return i.Video != nil && i.Video.ColorTransfer == "smpte2084" } // HDR reports a picture that needs tone mapping to look right on an SDR screen. (The player // tone-maps only PQ: HLG is made to be shown on an SDR screen as it is.) func (i Info) HDR() bool { return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67") } type probeJSON struct { Streams []struct { Index int `json:"index"` 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"` RFrameRate string `json:"r_frame_rate"` AvgFrameRate string `json:"avg_frame_rate"` ColorTransfer string `json:"color_transfer"` Channels int `json:"channels"` Disposition struct { AttachedPic int `json:"attached_pic"` } `json:"disposition"` SideData []struct { Type string `json:"side_data_type"` Projection string `json:"projection"` Rotation float64 `json:"rotation"` } `json:"side_data_list"` } `json:"streams"` Format struct { FormatName string `json:"format_name"` Duration string `json:"duration"` BitRate string `json:"bit_rate"` Size string `json:"size"` } `json:"format"` } // Inspect asks ffprobe what a file is. As root through sudo when asked, for a file only // root may read. An error about the file itself is a DecodeError. func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error) { if c.FFprobe == "" { return Info{}, errors.New("ffprobe is not installed") } argv := []string{c.FFprobe, "-v", "error", "-show_entries", "stream=index,codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,avg_frame_rate,color_transfer,channels,disposition:stream_side_data=side_data_type,projection,rotation:format=format_name,duration,bit_rate,size", "-of", "json", path} if root { argv = priv.Wrap(argv...) } cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) var errb bytes.Buffer cmd.Stderr = &errb out, err := cmd.Output() if err != nil { if ctx.Err() != nil { return Info{}, ctx.Err() } msg := lastLine(errb.String()) if msg == "" { msg = "ffprobe: " + err.Error() } return Info{}, &DecodeError{Msg: msg} } var pj probeJSON if err := json.Unmarshal(out, &pj); err != nil { return Info{}, &DecodeError{Msg: "ffprobe: unreadable answer"} } in := Info{Container: pj.Format.FormatName} in.Duration, _ = strconv.ParseFloat(pj.Format.Duration, 64) in.BitRate, _ = strconv.ParseInt(pj.Format.BitRate, 10, 64) in.Size, _ = strconv.ParseInt(pj.Format.Size, 10, 64) var art *Track for _, s := range pj.Streams { st := &Track{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height, ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0} // A 360 video says so in its spherical tag (ffmpeg reads the old XML one and the sv3d // box); an untagged one is still taken for 360 when it is 2:1 and too big for anything // else. for _, sd := range s.SideData { if sd.Type == "Spherical Mapping" && strings.HasPrefix(sd.Projection, "equirectangular") { st.Spherical = true } if sd.Type == "Display Matrix" { // ffprobe's rotation is counter-clockwise; ffmpeg turns it back the other way st.Rotation = ((-int(math.Round(sd.Rotation/90))*90)%360 + 360) % 360 } } st.Spherical = st.Spherical || (s.CodecType == "video" && s.Width == 2*s.Height && s.Width >= 3840) st.FPS = fps(s.AvgFrameRate) if st.FPS == 0 { st.FPS = fps(s.RFrameRate) } switch { case s.CodecType == "video" && st.AttachedPic: if art == nil { art = st } case s.CodecType == "video" && in.Video == nil: in.Video = st case s.CodecType == "audio" && in.Audio == nil: in.Audio = st } } if in.Video == nil { in.Video = art // cover art is the only picture there is } return in, nil } // fps parses ffprobe's "60000/1001". func fps(r string) float64 { n, d, ok := strings.Cut(r, "/") if !ok { v, _ := strconv.ParseFloat(r, 64) return v } nf, _ := strconv.ParseFloat(n, 64) df, _ := strconv.ParseFloat(d, 64) if df == 0 { return 0 } return nf / df }