Files: re-encode a video the browser can't play, on the machine's own GPU, and play that
The viewer tries the file as it is first. When the browser gives up — Safari and an MKV, HEVC without a hardware decoder — or on a tap, a picker: codec (H.264, HEVC, AV1, or the original codec repackaged) and size (original, 1080p, 720p, 480p), each codec labelled with the engine this machine would use. POST /api/files/play runs ffmpeg into an HLS event playlist of fragmented-MP4 segments under <data dir>/transcode/<id>/; Safari plays HLS itself and everything else goes through hls.js, loaded only then. The recipes are tried best first: a card on the bus before the integrated GPU, decoding on the same card before decoding on the CPU and uploading, the software encoders last; a recipe that produces no first segment is dropped for the next. The seekable range grows as ffmpeg gets ahead; status says how far. Streams are reused for the same request, closed when the player goes, swept after ten idle minutes, and the folder is emptied at start. Root mode runs ffmpeg as root through a helper that ends when the daemon lets go of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
aa4ae57c22
commit
f3c547adfa
+49
-12
@@ -39,6 +39,8 @@ type GPU struct {
|
||||
Device string `json:"device"` // /dev/dri/renderD128; "0" for cuda (its device index)
|
||||
Name string `json:"name,omitempty"` // "Intel Arc A380"
|
||||
Codecs []string `json:"codecs,omitempty"` // ffmpeg codec names it decodes; nil when nothing could say
|
||||
Encodes []string `json:"encodes,omitempty"` // ffmpeg codec names it encodes, from vainfo
|
||||
Integrated bool `json:"integrated,omitempty"` // on the CPU die; a card on the bus is preferred for encoding
|
||||
Reason string `json:"reason,omitempty"` // Unusable only: why
|
||||
Permission bool `json:"permission,omitempty"` // Unusable only: the node exists but this user may not open it
|
||||
}
|
||||
@@ -49,7 +51,8 @@ type Caps struct {
|
||||
FFprobe string `json:"ffprobe,omitempty"`
|
||||
Nice string `json:"nice,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
GPUs []GPU `json:"gpus"` // usable, in preference order
|
||||
GPUs []GPU `json:"gpus"` // usable, in node order
|
||||
Encoders []string `json:"encoders,omitempty"` // the encoders ffmpeg itself was built with that matter here
|
||||
Unusable []GPU `json:"unusable,omitempty"` // render nodes that exist but cannot be used, with the reason
|
||||
Error string `json:"error,omitempty"` // why nothing can be made at all
|
||||
ProbedAt time.Time `json:"probed_at"`
|
||||
@@ -139,13 +142,14 @@ func Probe(ctx context.Context) Caps {
|
||||
return c
|
||||
}
|
||||
c.Version = ffmpegVersion(ctx, c.FFmpeg)
|
||||
c.Encoders = encoders(ctx, c.FFmpeg)
|
||||
accels := hwaccels(ctx, c.FFmpeg)
|
||||
if accels["vaapi"] {
|
||||
nodes, _ := filepath.Glob("/dev/dri/renderD*")
|
||||
sort.Strings(nodes)
|
||||
vainfo, _ := exec.LookPath("vainfo")
|
||||
for _, dev := range nodes {
|
||||
g := GPU{API: "vaapi", Device: dev, Name: metrics.RenderNodeName(dev)}
|
||||
g := GPU{API: "vaapi", Device: dev, Name: metrics.RenderNodeName(dev), Integrated: metrics.RenderNodeIntegrated(dev)}
|
||||
if err := openable(dev); err != nil {
|
||||
g.Reason, g.Permission = err.Error(), errors.Is(err, os.ErrPermission)
|
||||
c.Unusable = append(c.Unusable, g)
|
||||
@@ -157,7 +161,7 @@ func Probe(ctx context.Context) Caps {
|
||||
continue
|
||||
}
|
||||
if vainfo != "" {
|
||||
g.Codecs = vainfoCodecs(ctx, vainfo, dev)
|
||||
g.Codecs, g.Encodes = vainfoCodecs(ctx, vainfo, dev)
|
||||
}
|
||||
c.GPUs = append(c.GPUs, g)
|
||||
}
|
||||
@@ -246,28 +250,61 @@ func hwaccels(ctx context.Context, ffmpeg string) map[string]bool {
|
||||
return m
|
||||
}
|
||||
|
||||
var vaProfileRe = regexp.MustCompile(`VAProfile([A-Za-z0-9]+?)(?:Simple|Main|High|Profile|Baseline|Constrained|Scc|Advanced|Version|Stereo|Multiview)\w*\s*:\s*VAEntrypointVLD`)
|
||||
var vaProfileRe = regexp.MustCompile(`VAProfile([A-Za-z0-9]+?)(?:Simple|Main|High|Profile|Baseline|Constrained|Scc|Advanced|Version|Stereo|Multiview)\w*\s*:\s*VAEntrypoint(VLD|EncSlice|EncSliceLP)\b`)
|
||||
|
||||
// wantedEncoders are the encoders this package may ask ffmpeg for.
|
||||
var wantedEncoders = set("libx264 libx265 libsvtav1 libaom-av1 libopenh264 h264_vaapi hevc_vaapi av1_vaapi aac")
|
||||
|
||||
// encoders lists the wanted encoders this ffmpeg was built with.
|
||||
func encoders(ctx context.Context, ffmpeg string) []string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-encoders").Output()
|
||||
var found []string
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) >= 2 && wantedEncoders[f[1]] {
|
||||
found = append(found, f[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
|
||||
// vaCodec maps a VAProfile family to the ffmpeg codec it decodes.
|
||||
var vaCodec = map[string]string{"H264": "h264", "HEVC": "hevc", "VP9": "vp9", "AV1": "av1", "MPEG2": "mpeg2video", "VC1": "vc1", "VP8": "vp8", "JPEG": "mjpeg", "MPEG4": "mpeg4", "H263": "h263"}
|
||||
|
||||
func vainfoCodecs(ctx context.Context, vainfo, dev string) []string {
|
||||
func vainfoCodecs(ctx context.Context, vainfo, dev string) (decodes, encodes []string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, vainfo, "--display", "drm", "--device", dev).Output()
|
||||
return parseVainfo(string(out))
|
||||
}
|
||||
|
||||
// parseVainfo keeps the decode (VLD) profiles and names their codecs the way ffmpeg does.
|
||||
func parseVainfo(text string) []string {
|
||||
seen := map[string]bool{}
|
||||
// parseVainfo names the codecs the decode (VLD) and encode (EncSlice) profiles cover,
|
||||
// the way ffmpeg names them.
|
||||
func parseVainfo(text string) (decodes, encodes []string) {
|
||||
dec, enc := map[string]bool{}, map[string]bool{}
|
||||
for _, m := range vaProfileRe.FindAllStringSubmatch(text, -1) {
|
||||
if c, ok := vaCodec[m[1]]; ok {
|
||||
seen[c] = true
|
||||
c, ok := vaCodec[m[1]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m[2] == "VLD" {
|
||||
dec[c] = true
|
||||
} else {
|
||||
enc[c] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for c := range seen {
|
||||
return keys(dec), keys(enc)
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for c := range m {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
|
||||
Reference in New Issue
Block a user