The viewer tries the file as it is first. When the browser gives up — Safari and an MKV, HEVC without a hardware decoder — or on a tap, a picker: codec (H.264, HEVC, AV1, or the original codec repackaged) and size (original, 1080p, 720p, 480p), each codec labelled with the engine this machine would use. POST /api/files/play runs ffmpeg into an HLS event playlist of fragmented-MP4 segments under <data dir>/transcode/<id>/; Safari plays HLS itself and everything else goes through hls.js, loaded only then. The recipes are tried best first: a card on the bus before the integrated GPU, decoding on the same card before decoding on the CPU and uploading, the software encoders last; a recipe that produces no first segment is dropped for the next. The seekable range grows as ffmpeg gets ahead; status says how far. Streams are reused for the same request, closed when the player goes, swept after ten idle minutes, and the folder is emptied at start. Root mode runs ffmpeg as root through a helper that ends when the daemon lets go of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
package media
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// HDR reports a picture that needs tone mapping to look right on an SDR screen.
|
|
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"`
|
|
} `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: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}
|
|
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
|
|
}
|