Files
hamlab/internal/media/encode.go
T
Mitchell-JohnsonandClaude Opus 5.5 450a9d3327 files: seek anywhere in a re-encode; it restarts from there
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>
2026-10-20 13:08:52 -07:00

286 lines
10 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 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
}