Files
hamlab/internal/media/inspect.go
T
Mitchell-JohnsonandClaude Opus 5.5 8c69826547 files: the player turns a phone's picture upright on the GPU, and shows HLG as it is
tonemap_vaapi refuses HLG, and the hardware path never honoured a rotation
(ffmpeg's autorotate is for frames in memory). HDR10 is still tone-mapped,
on the card and, failing that, in software before the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:56:16 -07:00

177 lines
6.2 KiB
Go

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
}