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 }