A re-encode's playlist is now the whole film on a fixed 4 s grid. A segment that isn't on disk and isn't coming soon moves the encode there; runs stop short of segments already made, and gaps are filled after. Every run is cut the same way (-ss, -output_ts_offset start+10, frag_discont), so segments from different runs join. Repackaging keeps its growing playlist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
10 KiB
Go
286 lines
10 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
|
||
|
||
// tsBase is added to every run's timestamps. A run from the top would otherwise start just
|
||
// below zero (the B-frame delay) and the muxer would shift it to make room, while a run from a
|
||
// seek point would not: with the base, no run is ever shifted, and a segment means the same
|
||
// stretch of the film whichever run made it.
|
||
const tsBase = 10
|
||
|
||
// segArgs is one run of a seekable re-encode: segments from..to-1 of the fixed 4 s grid.
|
||
// Every run is made the same way, the first included, so their segments are interchangeable:
|
||
// an accurate seek to from×4 s, timestamps put back where that is in the film (+ tsBase),
|
||
// kept as they are in each fragment (frag_discont), numbered from `from`, and an end just
|
||
// short of segment `to`, which is already on disk, so the run never writes over it. Segments
|
||
// land in dir as seg%05d.m4s; init is the init segment's name (only the first run's is used).
|
||
func segArgs(dir, in string, src Info, r Recipe, from, to, n int, init string) []string {
|
||
fps := 30.0
|
||
if src.Video != nil && src.Video.FPS > 0 {
|
||
fps = src.Video.FPS
|
||
}
|
||
start := from * hlsSeconds
|
||
a := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
|
||
a = append(a, r.Pre...)
|
||
a = append(a, "-ss", strconv.Itoa(start), "-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")
|
||
}
|
||
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))
|
||
if to < n {
|
||
a = append(a, "-t", strconv.FormatFloat(float64((to-from)*hlsSeconds)-0.5/fps, 'f', 3, 64))
|
||
}
|
||
a = append(a, "-output_ts_offset", strconv.Itoa(start+tsBase),
|
||
"-f", "hls", "-hls_time", strconv.Itoa(hlsSeconds), "-hls_playlist_type", "event", "-hls_segment_type", "fmp4",
|
||
"-hls_segment_options", "movflags=+frag_discont", "-start_number", strconv.Itoa(from),
|
||
"-hls_fmp4_init_filename", init, "-hls_segment_filename", dir+"/seg%05d.m4s", "-hls_flags", "independent_segments+temp_file",
|
||
"-progress", dir+"/progress", "-stats_period", "1", dir+"/run.m3u8")
|
||
return a
|
||
}
|
||
|
||
// segCount is how many 4 s segments a seekable re-encode of a file this long lists. The last
|
||
// 0.3 s are left out of the count, so a container whose sound runs on past the picture never
|
||
// lists a segment no frame starts.
|
||
func segCount(duration float64) int {
|
||
d := duration - 0.3
|
||
if d < 0 {
|
||
d = 0
|
||
}
|
||
return int(d/hlsSeconds) + 1
|
||
}
|
||
|
||
func has(list []string, x string) bool {
|
||
for _, v := range list {
|
||
if v == x {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|