Files
hamlab/internal/media/encode.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

343 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package media
// What to run when a video is played in the browser: the qualities it is offered in (the
// ladder), the ways a machine could make one of them on the device asked for, in the order
// worth trying, and the ffmpeg argument list for one piece-making run. All decisions, no
// processes, so it can be tested. Everything is H.264 High and AAC: what every browser plays.
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// Rung is one quality the player can pick: a size, a frame rate and a bit rate.
type Rung struct {
Name string `json:"name"` // in URLs: the width, "1920"
Label string `json:"label"` // what the menu says: "1080s", "720p"
Width int `json:"width"`
Height int `json:"height"`
FPS float64 `json:"fps"`
Bitrate int `json:"bitrate"` // video, bits a second, on average
Peak int `json:"peak"` // what the playlist promises, sound included
level string // H.264 level, "5.2"
codecs string // for the master playlist
}
// One step of the ladder: the long side for a 360 video (its width), the short side for a flat
// one, and the bit rate (Mb/s ×10) at more than 30 frames a second and at 30 or fewer.
type step struct {
size int
label string
hi, lo int
cap30 bool
}
// A 360 picture is seen a quarter of its width at a time, so it needs more pixels than a flat
// one for the same sharpness; YouTube names its sizes by the flat picture they look like
// ("1080s"), and so does this.
var vrSteps = []step{
{3840, "2160s", 220, 160, false},
{2880, "1440s", 120, 90, false},
{1920, "1080s", 60, 45, false},
{1280, "720s", 25, 25, true},
}
var flatSteps = []step{
{2160, "2160p", 160, 120, false},
{1440, "1440p", 90, 65, false},
{1080, "1080p", 50, 35, false},
{720, "720p", 25, 25, true},
{480, "480p", 12, 12, true},
}
const audioRate = 160_000
func even(x float64) int { return int(math.Round(x/2)) * 2 }
// Ladder is the qualities a video is offered in, best first: every step no bigger than the
// picture itself, or the picture's own size if it is smaller than all of them.
func Ladder(i Info) []Rung {
v := i.Video
if v == nil || v.Width <= 0 || v.Height <= 0 {
return nil
}
vr := v.Spherical
steps := flatSteps
w, h := v.Shown()
src := min(w, h) // a flat picture is named by its short side
if vr {
steps, src = vrSteps, w
}
// A picture between two steps (a 2292×960 capture) gets its own size on top, at the bit
// rate of the step above, so the best on offer is never less than the file.
var out []Rung
for k, s := range steps {
if s.size > src {
continue
}
if len(out) == 0 && s.size < src && k > 0 { // never above the top step: H.264 stops there
own := steps[k-1]
own.size, own.label, own.cap30 = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr], false
out = append(out, rung(i, own))
}
out = append(out, rung(i, s))
}
if len(out) == 0 {
last := steps[len(steps)-1]
last.size, last.label = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr]
out = append(out, rung(i, last))
}
return out
}
func rung(i Info, s step) Rung {
v := i.Video
sw, sh := v.Shown()
var w, h int
switch {
case v.Spherical:
w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
case sw >= sh:
h, w = s.size, even(float64(s.size)*float64(sw)/float64(sh))
default: // standing up
w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
}
fps := v.FPS
if fps <= 0 {
fps = 30
}
for fps > 60.5 || (s.cap30 && fps > 30.5) {
fps /= 2
}
fps = math.Round(fps*1000) / 1000
br := s.lo
if fps > 30.5 {
br = s.hi
}
r := Rung{Name: strconv.Itoa(w), Label: s.label, Width: w, Height: h, FPS: fps, Bitrate: br * 100_000}
r.Peak = r.Bitrate*3/2 + audioRate
r.level = level(w, h, fps)
lv, _ := strconv.ParseFloat(r.level, 64)
r.codecs = fmt.Sprintf("avc1.6400%02x", int(math.Round(lv*10)))
if i.Audio != nil {
r.codecs += ",mp4a.40.2"
}
return r
}
// level is the lowest H.264 level that takes this many macroblocks a frame and a second.
func level(w, h int, fps float64) string {
mbs := ((w + 15) / 16) * ((h + 15) / 16)
rate := float64(mbs) * fps
for _, l := range []struct {
name string
mbps float64
frame int
}{
{"3.1", 108000, 3600}, {"3.2", 216000, 5120}, {"4.0", 245760, 8192}, {"4.2", 522240, 8704},
{"5.0", 589824, 22080}, {"5.1", 983040, 36864}, {"5.2", 2073600, 36864},
} {
if rate <= l.mbps && mbs <= l.frame {
return l.name
}
}
return "6.2"
}
// fpsArg is a frame rate as ffmpeg's fps filter takes it, exactly for the NTSC rates.
func fpsArg(f float64) string {
for _, n := range []float64{24, 30, 60, 120} {
if math.Abs(f-n*1000/1001) < 0.01 {
return fmt.Sprintf("%d/1001", int(n*1000))
}
}
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(f, 'f', 3, 64), "0"), ".")
}
// Recipe is one way to make a quality: the engine's name for the record, the device it runs
// on ("cpu" for the software encoder), and the ffmpeg arguments that go before the input, the
// video filter, and the video codec.
type Recipe struct {
Engine string
Device string
Pre []string
VF string
Video []string
}
// CPU is the device name for software encoding.
const CPU = "cpu"
// Device is one thing a machine can make H.264 on, for the Transcode device setting.
type Device struct {
ID string `json:"id"` // a render node, or "cpu"
Name string `json:"name"` // "Intel Arc A380", "CPU (libx264)"
Integrated bool `json:"integrated,omitempty"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"` // when not OK: why
}
// Devices lists what this machine could make the player's H.264 on: every GPU, usable or not
// (and why not), then the CPU.
func Devices(c Caps) []Device {
var out []Device
if c.Error != "" {
return []Device{{ID: CPU, Name: "CPU", Reason: c.Error}}
}
for _, g := range c.GPUs {
d := Device{ID: g.Device, Name: g.Name, Integrated: g.Integrated}
if d.Name == "" {
d.Name = g.Device
}
switch {
case g.API != "vaapi":
d.Reason = "only VAAPI devices encode here"
case g.Encodes != nil && !has(g.Encodes, "h264"):
d.Reason = "doesn't encode H.264"
default:
d.OK = true
}
out = append(out, d)
}
for _, g := range c.Unusable {
name := g.Name
if name == "" {
name = g.Device
}
out = append(out, Device{ID: g.Device, Name: name, Integrated: g.Integrated, Reason: g.Reason})
}
d := Device{ID: CPU, Name: "CPU (libx264)", OK: has(c.Encoders, "libx264")}
if !d.OK {
d.Reason = "this ffmpeg has no libx264"
}
return append(out, d)
}
// recipes are the ways to make quality r of src on device, best first: on a GPU, decoding on
// the same card (frames never leave it) before decoding in software and uploading; the CPU
// last, as the fallback whatever was asked. device "" is any: a card on the bus before the
// integrated GPU. An empty list means nothing here can.
func recipes(c Caps, device string, src Info, r Rung) []Recipe {
var out []Recipe
gpus := append([]GPU(nil), c.GPUs...)
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
kbps := r.Bitrate / 1000
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*3/2) + "k", "-bufsize", strconv.Itoa(kbps*2) + "k",
"-profile:v", "high", "-level", r.level}
fps := ""
if src.Video == nil || src.Video.FPS <= 0 || math.Abs(src.Video.FPS-r.FPS) > 0.01 {
fps = "fps=" + fpsArg(r.FPS) + ","
}
if device != CPU {
for i := range gpus {
g := &gpus[i]
// Encodes is vainfo's answer; without vainfo there is none, and the card is tried
// anyway, with the next recipe behind it.
if g.API != "vaapi" || (g.Encodes != nil && !has(g.Encodes, "h264")) || (device != "" && g.Device != device) {
continue
}
video := append(append([]string{"-c:v", "h264_vaapi"}, rate...), "-bf", "0")
name := g.Name
if name == "" {
name = g.Device
}
// The card doesn't turn a phone's picture by itself (ffmpeg's own turning is for
// pictures in memory), so it is told to, and ffmpeg is told not to.
scale := turn(src) + fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", r.Width, r.Height)
// HDR10 (PQ) is tone-mapped on the card, which needs the file's mastering data;
// HLG is made to look right on an ordinary screen as it is, so it is only scaled
// (tonemap_vaapi refuses it). Either way the next recipe tone-maps in software.
if src.Video != nil && has(g.Codecs, src.Video.Codec) {
vf := fps + scale
if src.PQ() {
vf = fps + "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + scale
}
out = append(out, Recipe{Engine: name, Device: g.Device, Pre: []string{"-noautorotate", "-hwaccel", "vaapi", "-hwaccel_device", g.Device, "-hwaccel_output_format", "vaapi"}, VF: vf, Video: video})
}
cpuVF := fmt.Sprintf("%sformat=nv12,scale=%d:%d,hwupload", fps, r.Width, r.Height)
if src.PQ() {
cpuVF = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=nv12,hwupload", r.Width, r.Height)
}
out = append(out, Recipe{Engine: name + ", decoded on the CPU", Device: g.Device, Pre: []string{"-init_hw_device", "vaapi=va:" + g.Device, "-filter_hw_device", "va"},
VF: cpuVF, Video: video})
}
}
if has(c.Encoders, "libx264") {
vf := fmt.Sprintf("%sscale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
if src.PQ() {
vf = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=yuv420p", r.Width, r.Height)
}
out = append(out, Recipe{Engine: "CPU (libx264)", Device: CPU, VF: vf, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-sc_threshold", "0"}, rate...)})
}
return out
}
// turn is the card's filter that turns a picture the way it is meant to be shown.
func turn(src Info) string {
if src.Video == nil {
return ""
}
switch src.Video.Rotation {
case 90:
return "transpose_vaapi=dir=clock,"
case 180:
return "transpose_vaapi=dir=reversal,"
case 270:
return "transpose_vaapi=dir=cclock,"
}
return ""
}
// toneMap turns an HDR10 picture into an ordinary one, in software.
const toneMap = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv"
// Seg is the length of a piece, in seconds.
const Seg = 3.0
// tsBase is added to every run's times, so pieces from different runs follow on: without it
// a run from the top would start just below zero (the B-frame and AAC delay) and the muxer
// would shift that run alone.
const tsBase = 10
// pieceArgs is one run making quality r from piece n on, into dir as <n>.ts: an accurate seek
// to n×Seg, a keyframe on every Seg mark and nowhere else (so every piece starts on one, and
// pieces from any run and quality line up), times put back where that is in the film. in is a
// path or, for a file on another machine, an http URL.
func pieceArgs(dir, in string, src Info, rc Recipe, r Rung, n int) []string {
at := strconv.FormatFloat(float64(n)*Seg, 'f', 3, 64)
a := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
a = append(a, rc.Pre...)
if strings.HasPrefix(in, "http://") || strings.HasPrefix(in, "https://") {
a = append(a, "-reconnect", "1", "-reconnect_on_network_error", "1", "-reconnect_delay_max", "4")
}
a = append(a, "-ss", at, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
if rc.VF != "" {
a = append(a, "-vf", rc.VF)
}
a = append(a, rc.Video...)
if src.Audio == nil {
a = append(a, "-an")
} else {
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
}
gop := int(math.Ceil(r.FPS*Seg)) * 4
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%g)", Seg),
"-output_ts_offset", strconv.FormatFloat(float64(n)*Seg+tsBase, 'f', 3, 64),
"-f", "hls", "-hls_time", strconv.FormatFloat(Seg-0.5, 'f', 2, 64), "-hls_list_size", "0",
"-hls_flags", "temp_file", "-hls_segment_type", "mpegts", "-start_number", strconv.Itoa(n),
"-hls_segment_filename", dir+"/%d.ts", dir+"/ffmpeg.m3u8")
return a
}
func has(list []string, x string) bool {
for _, v := range list {
if v == x {
return true
}
}
return false
}