Found by walking every handler and the UI with odd inputs, then a battery of requests against the live hub. Fixed: - A save over the 8 MiB cap was cut short in place: the file was truncated, then the first 8 MiB written, and the caller told 204. Now the body is read whole first and a body over the cap is a 413 with the file untouched. - A link to a file showed the link's own time; now the file's, so the row reads right and the preview key follows the file. - Reading a link to nothing answered 200 with an empty body (the open failed after the headers in root mode); now a 404 that names the target. - A play request with an unknown codec or size was answered by inspecting the file first, so an audio-only file said "no video stream" instead of what was wrong; the choice is checked before any work, and is a 400. - A stream that ended in failure was handed back to the next identical request for ten minutes; a failed one is now dropped and a fresh one made. - A player that left while its stream was still finding a first segment made the loop try the next recipe; it stops. A stream closed mid-start is not started again. - Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's processes, so the wait is bounded and the helper's own grace does the rest. - A page going away (a phone closing the tab) releases its stream by a beacon, rather than ten minutes later by the sweeper. - The path box resolves like a shell — relative to the folder shown, . and .. applied, trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path that is a file opens it; a remembered folder that is gone falls back to / once. - A dropped folder arrived as a nameless empty file; it is refused with a word. The upload toast counts what was actually uploaded. - The viewer names a device, pipe or socket, and a dangling link, instead of calling them binary files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
package media
|
|
|
|
// What to run when a file is to be re-encoded for a browser that cannot play it as it is:
|
|
// the codecs and sizes on offer, the output size and bit rate a choice comes to for a
|
|
// given source, the ways this machine could do it in the order worth trying, and the
|
|
// ffmpeg argument list for one of them. All decisions, no processes, so it can be tested.
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
)
|
|
|
|
// Codec is one target a stream may be re-encoded to. "copy" keeps the codec and only
|
|
// repackages: for a browser that lacks the container, not the decoder.
|
|
type Codec struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
var Codecs = []Codec{{"h264", "H.264"}, {"hevc", "HEVC"}, {"av1", "AV1"}, {"copy", "Original codec, repackaged"}}
|
|
|
|
// Quality is a picture height; the bit rate follows from the size the output ends up at.
|
|
type Quality struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Height int `json:"height"` // 0: the source's own
|
|
}
|
|
|
|
var Qualities = []Quality{{"original", "Original size", 0}, {"1080p", "1080p", 1080}, {"720p", "720p", 720}, {"480p", "480p", 480}}
|
|
|
|
func qualityByID(id string) (Quality, bool) {
|
|
for _, q := range Qualities {
|
|
if q.ID == id {
|
|
return q, true
|
|
}
|
|
}
|
|
return Quality{}, false
|
|
}
|
|
|
|
// ValidChoice says whether a codec and quality are ones on offer — checked before any work.
|
|
func ValidChoice(codec, quality string) error {
|
|
if !codecKnown(codec) {
|
|
return fmt.Errorf("unknown codec %q", codec)
|
|
}
|
|
if _, ok := qualityByID(quality); !ok {
|
|
return fmt.Errorf("unknown quality %q", quality)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func codecKnown(id string) bool {
|
|
for _, c := range Codecs {
|
|
if c.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fit is the output: never larger than the source, even dimensions, and a bit rate by the
|
|
// size it comes to — a quarter more at 50 fps and up, less for the codecs that need less.
|
|
func fit(q Quality, src Info, codec string) (w, h, kbps int) {
|
|
w, h = src.Video.Width, src.Video.Height
|
|
if q.Height > 0 && h > q.Height {
|
|
w = int(math.Round(float64(w) * float64(q.Height) / float64(h)))
|
|
h = q.Height
|
|
}
|
|
w, h = w&^1, h&^1
|
|
switch {
|
|
case h >= 2160:
|
|
kbps = 20000
|
|
case h >= 1440:
|
|
kbps = 12000
|
|
case h >= 1080:
|
|
kbps = 8000
|
|
case h >= 720:
|
|
kbps = 4000
|
|
default:
|
|
kbps = 2000
|
|
}
|
|
if src.Video.FPS >= 50 {
|
|
kbps = kbps * 5 / 4
|
|
}
|
|
if codec == "hevc" || codec == "av1" {
|
|
kbps = kbps * 6 / 10
|
|
}
|
|
return w, h, kbps
|
|
}
|
|
|
|
// Recipe is one way to make the stream: the engine's name for the record, and the ffmpeg
|
|
// arguments that go before the input, the video filter, and the video codec.
|
|
type Recipe struct {
|
|
Engine string
|
|
Pre []string
|
|
VF string
|
|
Video []string
|
|
}
|
|
|
|
// recipes are the ways to try, best first: a card on the bus before the integrated GPU;
|
|
// on a GPU, decoding on the same card (frames never leave it) before decoding in software
|
|
// and uploading; the software encoders last. An empty list means nothing here can.
|
|
func recipes(c Caps, codec string, src Info, w, h, kbps int) []Recipe {
|
|
if codec == "copy" {
|
|
v := []string{"-c:v", "copy"}
|
|
if src.Video.Codec == "hevc" {
|
|
v = append(v, "-tag:v", "hvc1") // Safari reads hvc1, not hev1
|
|
}
|
|
return []Recipe{{Engine: "Original codec", Video: v}}
|
|
}
|
|
var out []Recipe
|
|
gpus := append([]GPU(nil), c.GPUs...)
|
|
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
|
|
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"}
|
|
for i := range gpus {
|
|
g := &gpus[i]
|
|
if g.API != "vaapi" || !has(g.Encodes, codec) {
|
|
continue
|
|
}
|
|
video := append([]string{"-c:v", codec + "_vaapi"}, rate...)
|
|
switch codec {
|
|
case "h264":
|
|
video = append(video, "-profile:v", "high")
|
|
case "hevc":
|
|
video = append(video, "-tag:v", "hvc1")
|
|
}
|
|
name := g.Name
|
|
if name == "" {
|
|
name = g.Device
|
|
}
|
|
scale := fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", w, h)
|
|
if has(g.Codecs, src.Video.Codec) {
|
|
vf := scale
|
|
if src.HDR() {
|
|
vf = "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + vf
|
|
}
|
|
out = append(out, Recipe{Engine: name + " (vaapi)", Pre: []string{"-hwaccel", "vaapi", "-hwaccel_device", g.Device, "-hwaccel_output_format", "vaapi"}, VF: vf, Video: video})
|
|
}
|
|
out = append(out, Recipe{Engine: name + " (vaapi, decoded on the CPU)", Pre: []string{"-init_hw_device", "vaapi=va:" + g.Device, "-filter_hw_device", "va"},
|
|
VF: fmt.Sprintf("format=nv12,scale=%d:%d,hwupload", w, h), Video: video})
|
|
}
|
|
swVF := fmt.Sprintf("scale=%d:%d,format=yuv420p", w, h)
|
|
if src.HDR() {
|
|
swVF = fmt.Sprintf("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709,scale=%d:%d,format=yuv420p", w, h)
|
|
}
|
|
cap := []string{"-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"}
|
|
switch {
|
|
case codec == "h264" && has(c.Encoders, "libx264"):
|
|
out = append(out, Recipe{Engine: "CPU (libx264)", VF: swVF, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-profile:v", "high"}, cap...)})
|
|
case codec == "hevc" && has(c.Encoders, "libx265"):
|
|
out = append(out, Recipe{Engine: "CPU (libx265)", VF: swVF, Video: append([]string{"-c:v", "libx265", "-preset", "fast", "-crf", "26", "-tag:v", "hvc1"}, cap...)})
|
|
case codec == "av1" && has(c.Encoders, "libsvtav1"):
|
|
out = append(out, Recipe{Engine: "CPU (SVT-AV1)", VF: swVF, Video: append([]string{"-c:v", "libsvtav1", "-preset", "8", "-crf", "35"}, cap...)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EngineFor is the engine the picker will say a codec runs on, "" when none can.
|
|
func EngineFor(c Caps, codec string) string {
|
|
if c.Error != "" {
|
|
return ""
|
|
}
|
|
if codec == "copy" {
|
|
return "Original codec"
|
|
}
|
|
r := recipes(c, codec, Info{Video: &Track{Codec: "h264", Width: 1920, Height: 1080}}, 1920, 1080, 8000)
|
|
if len(r) == 0 {
|
|
return ""
|
|
}
|
|
return r[0].Engine
|
|
}
|
|
|
|
// hlsArgs is the whole ffmpeg argument list for one recipe: the input, the first video and
|
|
// audio streams, the picture through the recipe, sound as AAC unless it already is, a
|
|
// keyframe every segment, and an event playlist of fragmented-MP4 segments in dir.
|
|
func hlsArgs(dir, in string, src Info, r Recipe, copyCodec bool) []string {
|
|
a := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
|
|
a = append(a, r.Pre...)
|
|
a = append(a, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
|
|
if r.VF != "" {
|
|
a = append(a, "-vf", r.VF)
|
|
}
|
|
a = append(a, r.Video...)
|
|
switch {
|
|
case src.Audio == nil:
|
|
a = append(a, "-an")
|
|
case src.Audio.Codec == "aac":
|
|
a = append(a, "-c:a", "copy")
|
|
default:
|
|
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
|
|
}
|
|
if !copyCodec {
|
|
fps := src.Video.FPS
|
|
if fps <= 0 {
|
|
fps = 30
|
|
}
|
|
gop := int(math.Round(fps * hlsSeconds))
|
|
if gop < 24 {
|
|
gop = 24
|
|
}
|
|
if gop > 300 {
|
|
gop = 300
|
|
}
|
|
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSeconds))
|
|
}
|
|
a = append(a, "-f", "hls", "-hls_time", strconv.Itoa(hlsSeconds), "-hls_playlist_type", "event", "-hls_segment_type", "fmp4",
|
|
"-hls_fmp4_init_filename", "init.mp4", "-hls_segment_filename", dir+"/seg%05d.m4s", "-hls_flags", "independent_segments+temp_file",
|
|
"-progress", dir+"/progress", "-stats_period", "1", dir+"/index.m3u8")
|
|
return a
|
|
}
|
|
|
|
const hlsSeconds = 4
|
|
|
|
func has(list []string, x string) bool {
|
|
for _, v := range list {
|
|
if v == x {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|