files: Ham Share's player — Auto quality, any by hand, or the file; 360 view; Transcode device setting
Every video plays YouTube's way: hls.js reads the connection and picks the quality, or one is picked by hand, or Original plays the file as it is. The qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead. 360 videos open in a look-around view (drag, pinch or scroll to zoom from 8 to 300 degrees, phone motion). Settings -> Transcode device picks the machine and device that makes them, for every machine's files: a machine transcoding another's file reads it through the hub by a token for that one file. Replaces the codec/size picker and the single-quality re-encoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f5f1cd79ba
commit
fc4126a0ed
+445
-108
@@ -1,141 +1,162 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// Playing a video the browser cannot play as it is: POST /api/files/play starts an HLS
|
// Playing a video in the Files viewer, YouTube's way: Auto (the player reads the connection),
|
||||||
// re-encode on this machine (internal/media), GET …/play/{id}/index.m3u8 and the segments
|
// any quality by hand, or the file itself. The qualities are made on the fly as HLS by
|
||||||
// feed the player, GET …/play/{id} is its progress, DELETE ends it. /api/files/play-caps
|
// internal/media, on the device the Transcode device setting names — which may be on another
|
||||||
// says which codecs this machine can make and on what. Root mode (?sudo=1) reads the file
|
// machine than the file.
|
||||||
// and runs ffmpeg as root, through the same sudo the file reads use.
|
//
|
||||||
|
// On every machine:
|
||||||
|
// POST /api/files/play[?sudo=1] {path | url, name, device} open a play
|
||||||
|
// GET /api/files/play/{id}/master.m3u8, /{rung}.m3u8, /{rung}/{n}.ts
|
||||||
|
// GET /api/files/play/{id} what it is doing
|
||||||
|
// DELETE /api/files/play/{id} (or POST …/close, for a beacon) end it
|
||||||
|
// GET /api/files/play-devices what it can encode on
|
||||||
|
// On the hub:
|
||||||
|
// POST /api/play {node, path, sudo} open a play of a file on node, wherever the setting
|
||||||
|
// says, answering which machine the play is on
|
||||||
|
// GET|PUT /api/transcode the setting, and every machine's devices
|
||||||
|
// GET /api/play-src/{token} a file, for a machine transcoding it, by a token
|
||||||
|
// that names that one file; ranges pass through
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
"hamlab/internal/media"
|
"hamlab/internal/media"
|
||||||
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const playLimit = 3 // re-encodes at once on one machine; a GPU does two or three, a CPU one
|
// playRuns is how many plays may be encoding at once on one machine; a fourth ends the one
|
||||||
|
// asked of least lately. The A380 makes about two 8K-sourced streams in real time.
|
||||||
|
const playRuns = 3
|
||||||
|
|
||||||
type playCaps struct {
|
type playReq struct {
|
||||||
Codecs []playCodec `json:"codecs"`
|
Path string `json:"path,omitempty"`
|
||||||
Qualities []media.Quality `json:"qualities"`
|
URL string `json:"url,omitempty"` // a file on another machine, as the hub serves it
|
||||||
Error string `json:"error,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
Device string `json:"device,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type playCodec struct {
|
type playOut struct {
|
||||||
media.Codec
|
Node string `json:"node"` // the machine the play is on: "" for the hub; filled in by the hub
|
||||||
Engine string `json:"engine,omitempty"` // "" when nothing here encodes it
|
Play *media.Play `json:"play"`
|
||||||
|
Master string `json:"master"` // the playlist, under that machine's /api
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
Host string `json:"host"` // that machine's name, for the menu
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Status media.PlayStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// playCapsHandler: GET /api/files/play-caps[?sudo=1]
|
// playDevices: GET /api/files/play-devices — what this machine could make the player's H.264
|
||||||
func (s *Server) playCapsHandler(w http.ResponseWriter, r *http.Request) {
|
// on, probed as the daemon's own user (a play from another machine never runs as root).
|
||||||
|
func (s *Server) playDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caps, err := s.thumbs.capsFor(r.Context(), false, false)
|
||||||
|
if err != nil {
|
||||||
|
caps.Error = err.Error()
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"host": s.hostName(), "devices": media.Devices(caps)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// playStart: POST /api/files/play[?sudo=1] — open a play of a file here, or of a URL the hub
|
||||||
|
// serves a file on another machine at.
|
||||||
|
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body playReq
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
root := r.URL.Query().Get("sudo") == "1"
|
root := r.URL.Query().Get("sudo") == "1"
|
||||||
if root && s.fsSudo == nil {
|
if root && s.fsSudo == nil {
|
||||||
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
caps, err := s.thumbs.capsFor(r.Context(), root, false)
|
out, code, err := s.openPlay(r.Context(), body, root)
|
||||||
pc := playCaps{Codecs: []playCodec{}, Qualities: media.Qualities}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pc.Error = err.Error()
|
jsonErr(w, code, err.Error())
|
||||||
} else if caps.Error != "" {
|
return
|
||||||
pc.Error = caps.Error
|
|
||||||
}
|
}
|
||||||
for _, c := range media.Codecs {
|
jsonOK(w, 200, out)
|
||||||
x := playCodec{Codec: c}
|
|
||||||
if pc.Error == "" {
|
|
||||||
x.Engine = media.EngineFor(caps, c.ID)
|
|
||||||
}
|
|
||||||
pc.Codecs = append(pc.Codecs, x)
|
|
||||||
}
|
|
||||||
jsonOK(w, 200, pc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// playStart: POST /api/files/play[?sudo=1] {path, codec, quality}
|
func (s *Server) openPlay(ctx context.Context, body playReq, root bool) (playOut, int, error) {
|
||||||
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
|
if root && s.fsSudo == nil && body.URL == "" {
|
||||||
fsys, ok := s.fsFor(w, r)
|
return playOut{}, 403, errors.New("root access needs passwordless sudo on the host")
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
root := r.URL.Query().Get("sudo") == "1"
|
in, name := body.Path, body.Name
|
||||||
var body struct{ Path, Codec, Quality string }
|
if body.URL != "" {
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
u, err := url.Parse(body.URL)
|
||||||
jsonErr(w, 400, "bad request")
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || body.Path != "" {
|
||||||
return
|
return playOut{}, 400, errors.New("a play is of a path or of an http URL")
|
||||||
}
|
}
|
||||||
|
in, root = body.URL, false
|
||||||
|
} else {
|
||||||
p, err := files.Clean(body.Path)
|
p, err := files.Clean(body.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 400, err.Error())
|
return playOut{}, 400, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if media.Kind(p) != "video" {
|
if media.Kind(p) != "video" {
|
||||||
jsonErr(w, 415, "not a video")
|
return playOut{}, 415, errors.New("not a video")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err := media.ValidChoice(body.Codec, body.Quality); err != nil {
|
fsys := s.fsLocal
|
||||||
jsonErr(w, 400, err.Error())
|
if root {
|
||||||
return
|
fsys = s.fsSudo
|
||||||
}
|
}
|
||||||
if _, err := fsys.Stat(r.Context(), p); err != nil {
|
if _, err := fsys.Stat(ctx, p); err != nil {
|
||||||
fsErr(w, err)
|
return playOut{}, 404, err
|
||||||
return
|
}
|
||||||
|
in = p
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caps, err := s.thumbs.capsFor(ctx, root, false)
|
||||||
|
if err == nil && caps.Error != "" {
|
||||||
|
err = errors.New(caps.Error)
|
||||||
}
|
}
|
||||||
caps, err := s.thumbs.capsFor(r.Context(), root, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 503, err.Error())
|
return playOut{}, 503, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if caps.Error != "" {
|
ictx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
jsonErr(w, 503, caps.Error)
|
src, err := media.Inspect(ictx, caps, in, root)
|
||||||
return
|
|
||||||
}
|
|
||||||
ictx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
||||||
src, err := media.Inspect(ictx, caps, p, root)
|
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
playErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
st, err := s.plays.Open(r.Context(), caps, root, p, body.Codec, body.Quality, src)
|
|
||||||
if err != nil {
|
|
||||||
playErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("files: play %s as %s %s on %s (from %s)", p, st.Codec, st.Quality, st.Engine, clientIP(r))
|
|
||||||
jsonOK(w, 200, map[string]any{"stream": st, "url": "/api/files/play/" + st.ID + "/index.m3u8"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func playErr(w http.ResponseWriter, err error) {
|
|
||||||
var de *media.DecodeError
|
var de *media.DecodeError
|
||||||
switch {
|
if errors.As(err, &de) {
|
||||||
case errors.As(err, &de):
|
return playOut{}, 422, errors.New(de.Msg)
|
||||||
jsonErr(w, 422, de.Msg)
|
|
||||||
case strings.Contains(err.Error(), "already being re-encoded"):
|
|
||||||
jsonErr(w, 429, err.Error())
|
|
||||||
case strings.HasPrefix(err.Error(), "unknown ") || strings.HasPrefix(err.Error(), "nothing on this machine encodes"):
|
|
||||||
jsonErr(w, 400, err.Error())
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
jsonErr(w, 504, "the file took too long to read")
|
|
||||||
default:
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
}
|
}
|
||||||
|
return playOut{}, 504, err
|
||||||
|
}
|
||||||
|
p, err := s.plays.Open(caps, root, in, name, src, body.Device)
|
||||||
|
if err != nil {
|
||||||
|
return playOut{}, 422, err
|
||||||
|
}
|
||||||
|
log.Printf("files: play %s on %s (%d qualities, %s)", name, p.Engine(), len(p.Rungs), map[bool]string{true: "from another machine", false: "here"}[p.Remote])
|
||||||
|
return playOut{Play: p, Master: "/api/files/play/" + p.ID + "/master.m3u8", Engine: p.Engine(), Host: s.hostName(), Status: p.Status()}, 200, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// playStatus: GET /api/files/play/{id}
|
// playStatus: GET /api/files/play/{id}
|
||||||
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
st, ok := s.plays.Find(r.PathValue("id"))
|
p, ok := s.plays.Find(r.PathValue("id"))
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonErr(w, 404, "no such stream")
|
jsonErr(w, 404, "no such play")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonOK(w, 200, st.Status())
|
jsonOK(w, 200, p.Status())
|
||||||
}
|
}
|
||||||
|
|
||||||
// playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its
|
// playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its
|
||||||
@@ -145,33 +166,47 @@ func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
// playFile: GET /api/files/play/{id}/{file} — the playlist, the init segment, a segment.
|
// playPlaylist: GET /api/files/play/{id}/{file} — master.m3u8, or one quality's <name>.m3u8.
|
||||||
// A seekable stream's playlist is written whole, up front; a segment of it that isn't on disk
|
func (s *Server) playPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
// yet is waited for, or re-encoded from there if the encode isn't heading that way (a seek).
|
p, ok := s.plays.Find(r.PathValue("id"))
|
||||||
func (s *Server) playFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
st, ok := s.plays.Find(r.PathValue("id"))
|
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonErr(w, 404, "no such stream")
|
jsonErr(w, 404, "no such play")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := r.PathValue("file")
|
f := r.PathValue("file")
|
||||||
p, ok := st.File(name)
|
var b []byte
|
||||||
if !ok {
|
if f == "master.m3u8" {
|
||||||
jsonErr(w, 404, "not a stream file")
|
b = p.Master()
|
||||||
|
} else if name, isList := strings.CutSuffix(f, ".m3u8"); isList {
|
||||||
|
if b, ok = p.Media(name); !ok {
|
||||||
|
jsonErr(w, 404, "no such quality")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
jsonErr(w, 404, "not a play file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
if st.Seekable {
|
|
||||||
if name == "index.m3u8" {
|
|
||||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||||
_, _ = w.Write(st.Playlist())
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// playPiece: GET /api/files/play/{id}/{rung}/{seg} — a piece, made first if need be.
|
||||||
|
func (s *Server) playPiece(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := s.plays.Find(r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such play")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if i, isSeg := media.SegmentIndex(name); isSeg {
|
ns, isTS := strings.CutSuffix(r.PathValue("seg"), ".ts")
|
||||||
sp, err := st.Segment(r.Context(), i)
|
n, err := strconv.Atoi(ns)
|
||||||
|
if !isTS || err != nil || strconv.Itoa(n) != ns {
|
||||||
|
jsonErr(w, 404, "not a piece")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := p.Segment(r.Context().Done(), r.PathValue("rung"), n)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, media.ErrNotInFilm):
|
case errors.Is(err, media.ErrNotFound):
|
||||||
jsonErr(w, 404, err.Error())
|
jsonErr(w, 404, err.Error())
|
||||||
return
|
return
|
||||||
case r.Context().Err() != nil:
|
case r.Context().Err() != nil:
|
||||||
@@ -180,14 +215,316 @@ func (s *Server) playFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 503, err.Error())
|
jsonErr(w, 503, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p = sp
|
w.Header().Set("Content-Type", "video/mp2t")
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
|
http.ServeFile(w, r, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- on the hub: where plays are made ----
|
||||||
|
|
||||||
|
// transcodeChoice is the setting; nil is the machine the file is on.
|
||||||
|
func (s *Server) transcodeChoice() *store.Transcode {
|
||||||
|
var t *store.Transcode
|
||||||
|
s.store.View(func(st *store.State) {
|
||||||
|
if st.Transcode != nil {
|
||||||
|
c := *st.Transcode
|
||||||
|
t = &c
|
||||||
}
|
}
|
||||||
switch filepath.Ext(p) {
|
})
|
||||||
case ".m3u8":
|
return t
|
||||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
}
|
||||||
|
|
||||||
|
type transcodeMachine struct {
|
||||||
|
Node string `json:"node"` // "" is the hub
|
||||||
|
Host string `json:"host"`
|
||||||
|
Devices []media.Device `json:"devices"`
|
||||||
|
Error string `json:"error,omitempty"` // the machine didn't answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTranscode: GET /api/transcode — the setting, and what every machine can encode on,
|
||||||
|
// asked of each now.
|
||||||
|
func (s *Server) getTranscode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
names := []string{""}
|
||||||
|
if s.nodes != nil {
|
||||||
|
names = append(names, s.nodes.Names()...)
|
||||||
|
}
|
||||||
|
out := make([]transcodeMachine, len(names))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, n := range names {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
m := transcodeMachine{Node: n, Host: n}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if n == "" {
|
||||||
|
caps, err := s.thumbs.capsFor(ctx, false, false)
|
||||||
|
if err != nil {
|
||||||
|
caps.Error = err.Error()
|
||||||
|
}
|
||||||
|
m.Host, m.Devices = s.hostName(), media.Devices(caps)
|
||||||
|
} else {
|
||||||
|
code, b, err := s.nodes.Do(ctx, n, "GET", "/api/files/play-devices", nil, nil)
|
||||||
|
var d struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Devices []media.Device `json:"devices"`
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
m.Error = err.Error()
|
||||||
|
case code == 404:
|
||||||
|
m.Error = "runs an older Ham Lab"
|
||||||
|
case code != 200 || json.Unmarshal(b, &d) != nil:
|
||||||
|
m.Error = fmt.Sprintf("answered %d", code)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Content-Type", "video/mp4")
|
m.Devices = d.Devices
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, p)
|
}
|
||||||
|
out[i] = m
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
jsonOK(w, 200, map[string]any{"selected": s.transcodeChoice(), "machines": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// putTranscode: PUT /api/transcode {node, device}, or {} for the machine the file is on.
|
||||||
|
func (s *Server) putTranscode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body *store.Transcode
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "expected {\"node\": …, \"device\": …}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body != nil && body.Device == "" {
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
known := body.Node == ""
|
||||||
|
if s.nodes != nil {
|
||||||
|
for _, n := range s.nodes.Names() {
|
||||||
|
known = known || n == body.Node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
jsonErr(w, 400, "no such machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Device != media.CPU && !strings.HasPrefix(body.Device, "/dev/dri/") {
|
||||||
|
jsonErr(w, 400, "a device is a render node or \"cpu\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.Update(func(st *store.State) { st.Transcode = body }); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.getTranscode(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubPlay: POST /api/play {node, path, sudo} — open a play of a file on node ("" the hub) on
|
||||||
|
// the device the setting names. When that is on another machine, that machine reads the file
|
||||||
|
// through this hub, by a token good for that one file. If it can't be reached, the file's own
|
||||||
|
// machine makes it instead, and the answer says so.
|
||||||
|
func (s *Server) hubPlay(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Sudo bool `json:"sudo"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := files.Clean(body.Path)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Node != "" && (s.nodes == nil || !has(s.nodes.Names(), body.Node)) {
|
||||||
|
jsonErr(w, 404, "no such machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
choice := s.transcodeChoice()
|
||||||
|
var note string
|
||||||
|
if choice != nil && choice.Node != body.Node {
|
||||||
|
if s.nodes != nil && choice.Node != "" && !has(s.nodes.Names(), choice.Node) {
|
||||||
|
note = "The transcode machine " + choice.Node + " is no longer managed here; made where the file is."
|
||||||
|
} else {
|
||||||
|
src, err := s.srcURL(choice.Node, body.Node, p, body.Sudo)
|
||||||
|
if err == nil {
|
||||||
|
req := playReq{URL: src, Name: filepath.Base(p), Device: choice.Device}
|
||||||
|
out, code, err := s.playOn(r.Context(), choice.Node, req, false)
|
||||||
|
if err == nil {
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code == 422 || code == 415 {
|
||||||
|
jsonErr(w, code, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("files: play on %s failed (%v); making it where the file is", nodeLabel(choice.Node), err)
|
||||||
|
note = "Couldn't make it on " + nodeLabel(choice.Node) + " (" + err.Error() + "), so it is made where the file is."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device := ""
|
||||||
|
if choice != nil && choice.Node == body.Node {
|
||||||
|
device = choice.Device
|
||||||
|
}
|
||||||
|
out, code, err := s.playOn(r.Context(), body.Node, playReq{Path: p, Device: device}, body.Sudo)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, code, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Note = note
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeLabel(n string) string {
|
||||||
|
if n == "" {
|
||||||
|
return "the hub"
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(list []string, x string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == x {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// playOn opens a play on a machine: here, or through that node's API.
|
||||||
|
func (s *Server) playOn(ctx context.Context, node string, req playReq, sudo bool) (playOut, int, error) {
|
||||||
|
if node == "" {
|
||||||
|
out, code, err := s.openPlay(ctx, req, sudo)
|
||||||
|
return out, code, err
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(req)
|
||||||
|
path := "/api/files/play"
|
||||||
|
if sudo {
|
||||||
|
path += "?sudo=1"
|
||||||
|
}
|
||||||
|
code, resp, err := s.nodes.Do(ctx, node, "POST", path, b, http.Header{"Content-Type": []string{"application/json"}})
|
||||||
|
if err != nil {
|
||||||
|
return playOut{}, 502, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
var e struct{ Error string }
|
||||||
|
_ = json.Unmarshal(resp, &e)
|
||||||
|
if e.Error == "" {
|
||||||
|
e.Error = fmt.Sprintf("%s answered %d", node, code)
|
||||||
|
}
|
||||||
|
return playOut{}, code, errors.New(e.Error)
|
||||||
|
}
|
||||||
|
var out playOut
|
||||||
|
if err := json.Unmarshal(resp, &out); err != nil {
|
||||||
|
return playOut{}, 502, err
|
||||||
|
}
|
||||||
|
out.Node = node
|
||||||
|
return out, 200, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the file, for the machine transcoding it ----
|
||||||
|
|
||||||
|
type srcGrant struct {
|
||||||
|
node, path string
|
||||||
|
sudo bool
|
||||||
|
until time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type srcGrants struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*srcGrant
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcLife = 12 * time.Hour // from its last use: a film paused overnight still plays
|
||||||
|
|
||||||
|
var grants = &srcGrants{m: map[string]*srcGrant{}}
|
||||||
|
|
||||||
|
func (g *srcGrants) mint(node, path string, sudo bool) string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
tok := hex.EncodeToString(b)
|
||||||
|
now := time.Now()
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
for k, v := range g.m {
|
||||||
|
if now.After(v.until) {
|
||||||
|
delete(g.m, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.m[tok] = &srcGrant{node: node, path: path, sudo: sudo, until: now.Add(srcLife)}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *srcGrants) use(tok string) (srcGrant, bool) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
v, ok := g.m[tok]
|
||||||
|
if !ok || time.Now().After(v.until) {
|
||||||
|
return srcGrant{}, false
|
||||||
|
}
|
||||||
|
v.until = time.Now().Add(srcLife)
|
||||||
|
return *v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// srcURL is where the machine `to` can read a file on machine `from` through this hub: this
|
||||||
|
// hub's own address as `to` reaches it, and a token for that one file.
|
||||||
|
func (s *Server) srcURL(to, from, path string, sudo bool) (string, error) {
|
||||||
|
host, port, err := net.SplitHostPort(s.cfg.Listen)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host == "" || ip == nil || ip.IsUnspecified() || ip.IsLoopback() {
|
||||||
|
// Listening everywhere: the address this machine reaches `to` from is one it answers on.
|
||||||
|
target := "127.0.0.1:1"
|
||||||
|
if to != "" {
|
||||||
|
if u, err := url.Parse(s.nodes.URL(to)); err == nil && u.Host != "" {
|
||||||
|
target = u.Host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := net.Dial("udp", target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
host = c.LocalAddr().(*net.UDPAddr).IP.String()
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if s.fingerprint != "" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return scheme + "://" + net.JoinHostPort(host, port) + "/api/play-src/" + grants.mint(from, path, sudo), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// playSrc: GET /api/play-src/{token} — the one file a token names, ranges and all, read from
|
||||||
|
// its machine. The token is the only key: it is minted for a machine transcoding that file.
|
||||||
|
func (s *Server) playSrc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, ok := grants.use(r.PathValue("token"))
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := url.Values{"path": {g.path}}
|
||||||
|
if g.sudo {
|
||||||
|
q.Set("sudo", "1")
|
||||||
|
}
|
||||||
|
r2 := r.Clone(r.Context())
|
||||||
|
r2.URL.RawQuery = q.Encode()
|
||||||
|
r2.Header.Del("Cookie")
|
||||||
|
if g.node == "" {
|
||||||
|
r2.URL.Path = "/api/files/read"
|
||||||
|
s.filesRead(w, r2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, ok := s.nodes.Proxy(g.node)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r2.URL.Path = "/api/nodes/" + g.node + "/files/read"
|
||||||
|
p.ServeHTTP(w, r2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/files"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The file a machine transcodes from another is read through the hub by a token naming that
|
||||||
|
// one file: ranges pass through, the query can't point it anywhere else, and an unknown token
|
||||||
|
// is nothing.
|
||||||
|
func TestPlaySrc(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "film.mp4")
|
||||||
|
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "other.mp4"), []byte("secret"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{fsLocal: files.Local{}, cfg: &config.Config{Listen: "10.0.0.234:8100"}}
|
||||||
|
u, err := s.srcURL("", "", p, false)
|
||||||
|
if err != nil || !strings.HasPrefix(u, "http://10.0.0.234:8100/api/play-src/") {
|
||||||
|
t.Fatalf("url %q %v", u, err)
|
||||||
|
}
|
||||||
|
tok := strings.TrimPrefix(u, "http://10.0.0.234:8100/api/play-src/")
|
||||||
|
get := func(tok, query, rng string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest("GET", "/api/play-src/"+tok+query, nil)
|
||||||
|
r.SetPathValue("token", tok)
|
||||||
|
if rng != "" {
|
||||||
|
r.Header.Set("Range", rng)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.playSrc(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if w := get(tok, "", "bytes=2-4"); w.Code != 206 || w.Body.String() != "234" {
|
||||||
|
t.Fatalf("range: %d %q", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if w := get(tok, "?path="+filepath.Join(dir, "other.mp4"), ""); w.Body.String() != "0123456789" {
|
||||||
|
t.Fatalf("the query moved it: %q", w.Body)
|
||||||
|
}
|
||||||
|
if w := get("nope", "", ""); w.Code != 404 {
|
||||||
|
t.Fatalf("unknown token: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ type Server struct {
|
|||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
thumbs *thumbSvc
|
thumbs *thumbSvc
|
||||||
plays *media.Streamer
|
plays *media.Plays
|
||||||
self string // this binary, for helpers run as tasks
|
self string // this binary, for helpers run as tasks
|
||||||
nodes *nodes.Manager
|
nodes *nodes.Manager
|
||||||
fingerprint string
|
fingerprint string
|
||||||
@@ -103,7 +103,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
s.self = self
|
s.self = self
|
||||||
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
||||||
s.thumbs = newThumbSvc(cfg.DataDir(), self)
|
s.thumbs = newThumbSvc(cfg.DataDir(), self)
|
||||||
s.plays = media.NewStreamer(cfg.DataDir(), self, playLimit)
|
s.plays = media.NewPlays(cfg.DataDir(), self, playRuns)
|
||||||
s.hostName() // read it once now so the first request doesn't have to
|
s.hostName() // read it once now so the first request doesn't have to
|
||||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||||
s.user = u.Username
|
s.user = u.Username
|
||||||
@@ -279,12 +279,17 @@ func (s *Server) routes() *http.ServeMux {
|
|||||||
m.HandleFunc("GET /api/files/thumbs", s.auth(s.thumbsStatus))
|
m.HandleFunc("GET /api/files/thumbs", s.auth(s.thumbsStatus))
|
||||||
m.HandleFunc("POST /api/files/thumbs/install", s.auth(s.thumbsInstall))
|
m.HandleFunc("POST /api/files/thumbs/install", s.auth(s.thumbsInstall))
|
||||||
m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess))
|
m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess))
|
||||||
m.HandleFunc("GET /api/files/play-caps", s.auth(s.playCapsHandler))
|
m.HandleFunc("GET /api/files/play-devices", s.auth(s.playDevices))
|
||||||
m.HandleFunc("POST /api/files/play", s.auth(s.playStart))
|
m.HandleFunc("POST /api/files/play", s.auth(s.playStart))
|
||||||
m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus))
|
m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus))
|
||||||
m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop))
|
m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop))
|
||||||
m.HandleFunc("POST /api/files/play/{id}/close", s.auth(s.playStop))
|
m.HandleFunc("POST /api/files/play/{id}/close", s.auth(s.playStop))
|
||||||
m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.playFile))
|
m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.playPlaylist))
|
||||||
|
m.HandleFunc("GET /api/files/play/{id}/{rung}/{seg}", s.auth(s.playPiece))
|
||||||
|
m.HandleFunc("POST /api/play", s.auth(s.hubPlay))
|
||||||
|
m.HandleFunc("GET /api/transcode", s.auth(s.getTranscode))
|
||||||
|
m.HandleFunc("PUT /api/transcode", s.auth(s.putTranscode))
|
||||||
|
m.HandleFunc("GET /api/play-src/{token}", s.playSrc) // the token is the key
|
||||||
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
||||||
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
||||||
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
|
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
|
||||||
|
|||||||
+237
-212
@@ -1,280 +1,305 @@
|
|||||||
package media
|
package media
|
||||||
|
|
||||||
// What to run when a file is to be re-encoded for a browser that cannot play it as it is:
|
// What to run when a video is played in the browser: the qualities it is offered in (the
|
||||||
// the codecs and sizes on offer, the output size and bit rate a choice comes to for a
|
// ladder), the ways a machine could make one of them on the device asked for, in the order
|
||||||
// given source, the ways this machine could do it in the order worth trying, and the
|
// worth trying, and the ffmpeg argument list for one piece-making run. All decisions, no
|
||||||
// ffmpeg argument list for one of them. All decisions, no processes, so it can be tested.
|
// processes, so it can be tested. Everything is H.264 High and AAC: what every browser plays.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Codec is one target a stream may be re-encoded to. "copy" keeps the codec and only
|
// Rung is one quality the player can pick: a size, a frame rate and a bit rate.
|
||||||
// repackages: for a browser that lacks the container, not the decoder.
|
type Rung struct {
|
||||||
type Codec struct {
|
Name string `json:"name"` // in URLs: the width, "1920"
|
||||||
ID string `json:"id"`
|
Label string `json:"label"` // what the menu says: "1080s", "720p"
|
||||||
Label string `json:"label"`
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
FPS float64 `json:"fps"`
|
||||||
|
Bitrate int `json:"bitrate"` // video, bits a second, on average
|
||||||
|
Peak int `json:"peak"` // what the playlist promises, sound included
|
||||||
|
level string // H.264 level, "5.2"
|
||||||
|
codecs string // for the master playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
var Codecs = []Codec{{"h264", "H.264"}, {"hevc", "HEVC"}, {"av1", "AV1"}, {"copy", "Original codec, repackaged"}}
|
// One step of the ladder: the long side for a 360 video (its width), the short side for a flat
|
||||||
|
// one, and the bit rate (Mb/s ×10) at more than 30 frames a second and at 30 or fewer.
|
||||||
// Quality is a picture height; the bit rate follows from the size the output ends up at.
|
type step struct {
|
||||||
type Quality struct {
|
size int
|
||||||
ID string `json:"id"`
|
label string
|
||||||
Label string `json:"label"`
|
hi, lo int
|
||||||
Height int `json:"height"` // 0: the source's own
|
cap30 bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var Qualities = []Quality{{"original", "Original size", 0}, {"1080p", "1080p", 1080}, {"720p", "720p", 720}, {"480p", "480p", 480}}
|
// A 360 picture is seen a quarter of its width at a time, so it needs more pixels than a flat
|
||||||
|
// one for the same sharpness; YouTube names its sizes by the flat picture they look like
|
||||||
func qualityByID(id string) (Quality, bool) {
|
// ("1080s"), and so does this.
|
||||||
for _, q := range Qualities {
|
var vrSteps = []step{
|
||||||
if q.ID == id {
|
{3840, "2160s", 220, 160, false},
|
||||||
return q, true
|
{2880, "1440s", 120, 90, false},
|
||||||
}
|
{1920, "1080s", 60, 45, false},
|
||||||
}
|
{1280, "720s", 25, 25, true},
|
||||||
return Quality{}, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidChoice says whether a codec and quality are ones on offer — checked before any work.
|
var flatSteps = []step{
|
||||||
func ValidChoice(codec, quality string) error {
|
{2160, "2160p", 160, 120, false},
|
||||||
if !codecKnown(codec) {
|
{1440, "1440p", 90, 65, false},
|
||||||
return fmt.Errorf("unknown codec %q", codec)
|
{1080, "1080p", 50, 35, false},
|
||||||
}
|
{720, "720p", 25, 25, true},
|
||||||
if _, ok := qualityByID(quality); !ok {
|
{480, "480p", 12, 12, true},
|
||||||
return fmt.Errorf("unknown quality %q", quality)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const audioRate = 160_000
|
||||||
|
|
||||||
|
func even(x float64) int { return int(math.Round(x/2)) * 2 }
|
||||||
|
|
||||||
|
// Ladder is the qualities a video is offered in, best first: every step no bigger than the
|
||||||
|
// picture itself, or the picture's own size if it is smaller than all of them.
|
||||||
|
func Ladder(i Info) []Rung {
|
||||||
|
v := i.Video
|
||||||
|
if v == nil || v.Width <= 0 || v.Height <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
vr := v.Spherical
|
||||||
func codecKnown(id string) bool {
|
steps := flatSteps
|
||||||
for _, c := range Codecs {
|
src := min(v.Width, v.Height) // a flat picture is named by its short side
|
||||||
if c.ID == id {
|
if vr {
|
||||||
return true
|
steps, src = vrSteps, v.Width
|
||||||
}
|
}
|
||||||
|
// A picture between two steps (a 2292×960 capture) gets its own size on top, at the bit
|
||||||
|
// rate of the step above, so the best on offer is never less than the file.
|
||||||
|
var out []Rung
|
||||||
|
for k, s := range steps {
|
||||||
|
if s.size > src {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return false
|
if len(out) == 0 && s.size < src && k > 0 { // never above the top step: H.264 stops there
|
||||||
|
own := steps[k-1]
|
||||||
|
own.size, own.label, own.cap30 = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr], false
|
||||||
|
out = append(out, rung(i, own))
|
||||||
|
}
|
||||||
|
out = append(out, rung(i, s))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
last := steps[len(steps)-1]
|
||||||
|
last.size, last.label = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr]
|
||||||
|
out = append(out, rung(i, last))
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// fit is the output: never larger than the source, even dimensions, and a bit rate by the
|
func rung(i Info, s step) Rung {
|
||||||
// size it comes to — a quarter more at 50 fps and up, less for the codecs that need less.
|
v := i.Video
|
||||||
func fit(q Quality, src Info, codec string) (w, h, kbps int) {
|
var w, h 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 {
|
switch {
|
||||||
case h >= 2160:
|
case v.Spherical:
|
||||||
kbps = 20000
|
w, h = s.size, even(float64(s.size)*float64(v.Height)/float64(v.Width))
|
||||||
case h >= 1440:
|
case v.Width >= v.Height:
|
||||||
kbps = 12000
|
h, w = s.size, even(float64(s.size)*float64(v.Width)/float64(v.Height))
|
||||||
case h >= 1080:
|
default: // standing up
|
||||||
kbps = 8000
|
w, h = s.size, even(float64(s.size)*float64(v.Height)/float64(v.Width))
|
||||||
case h >= 720:
|
|
||||||
kbps = 4000
|
|
||||||
default:
|
|
||||||
kbps = 2000
|
|
||||||
}
|
}
|
||||||
if src.Video.FPS >= 50 {
|
fps := v.FPS
|
||||||
kbps = kbps * 5 / 4
|
if fps <= 0 {
|
||||||
|
fps = 30
|
||||||
}
|
}
|
||||||
if codec == "hevc" || codec == "av1" {
|
for fps > 60.5 || (s.cap30 && fps > 30.5) {
|
||||||
kbps = kbps * 6 / 10
|
fps /= 2
|
||||||
}
|
}
|
||||||
return w, h, kbps
|
fps = math.Round(fps*1000) / 1000
|
||||||
|
br := s.lo
|
||||||
|
if fps > 30.5 {
|
||||||
|
br = s.hi
|
||||||
|
}
|
||||||
|
r := Rung{Name: strconv.Itoa(w), Label: s.label, Width: w, Height: h, FPS: fps, Bitrate: br * 100_000}
|
||||||
|
r.Peak = r.Bitrate*3/2 + audioRate
|
||||||
|
r.level = level(w, h, fps)
|
||||||
|
lv, _ := strconv.ParseFloat(r.level, 64)
|
||||||
|
r.codecs = fmt.Sprintf("avc1.6400%02x", int(math.Round(lv*10)))
|
||||||
|
if i.Audio != nil {
|
||||||
|
r.codecs += ",mp4a.40.2"
|
||||||
|
}
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipe is one way to make the stream: the engine's name for the record, and the ffmpeg
|
// level is the lowest H.264 level that takes this many macroblocks a frame and a second.
|
||||||
// arguments that go before the input, the video filter, and the video codec.
|
func level(w, h int, fps float64) string {
|
||||||
|
mbs := ((w + 15) / 16) * ((h + 15) / 16)
|
||||||
|
rate := float64(mbs) * fps
|
||||||
|
for _, l := range []struct {
|
||||||
|
name string
|
||||||
|
mbps float64
|
||||||
|
frame int
|
||||||
|
}{
|
||||||
|
{"3.1", 108000, 3600}, {"3.2", 216000, 5120}, {"4.0", 245760, 8192}, {"4.2", 522240, 8704},
|
||||||
|
{"5.0", 589824, 22080}, {"5.1", 983040, 36864}, {"5.2", 2073600, 36864},
|
||||||
|
} {
|
||||||
|
if rate <= l.mbps && mbs <= l.frame {
|
||||||
|
return l.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "6.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// fpsArg is a frame rate as ffmpeg's fps filter takes it, exactly for the NTSC rates.
|
||||||
|
func fpsArg(f float64) string {
|
||||||
|
for _, n := range []float64{24, 30, 60, 120} {
|
||||||
|
if math.Abs(f-n*1000/1001) < 0.01 {
|
||||||
|
return fmt.Sprintf("%d/1001", int(n*1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(f, 'f', 3, 64), "0"), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipe is one way to make a quality: the engine's name for the record, the device it runs
|
||||||
|
// on ("cpu" for the software encoder), and the ffmpeg arguments that go before the input, the
|
||||||
|
// video filter, and the video codec.
|
||||||
type Recipe struct {
|
type Recipe struct {
|
||||||
Engine string
|
Engine string
|
||||||
|
Device string
|
||||||
Pre []string
|
Pre []string
|
||||||
VF string
|
VF string
|
||||||
Video []string
|
Video []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// recipes are the ways to try, best first: a card on the bus before the integrated GPU;
|
// CPU is the device name for software encoding.
|
||||||
// on a GPU, decoding on the same card (frames never leave it) before decoding in software
|
const CPU = "cpu"
|
||||||
// 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 {
|
// Device is one thing a machine can make H.264 on, for the Transcode device setting.
|
||||||
if codec == "copy" {
|
type Device struct {
|
||||||
v := []string{"-c:v", "copy"}
|
ID string `json:"id"` // a render node, or "cpu"
|
||||||
if src.Video.Codec == "hevc" {
|
Name string `json:"name"` // "Intel Arc A380", "CPU (libx264)"
|
||||||
v = append(v, "-tag:v", "hvc1") // Safari reads hvc1, not hev1
|
Integrated bool `json:"integrated,omitempty"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Reason string `json:"reason,omitempty"` // when not OK: why
|
||||||
}
|
}
|
||||||
return []Recipe{{Engine: "Original codec", Video: v}}
|
|
||||||
|
// Devices lists what this machine could make the player's H.264 on: every GPU, usable or not
|
||||||
|
// (and why not), then the CPU.
|
||||||
|
func Devices(c Caps) []Device {
|
||||||
|
var out []Device
|
||||||
|
if c.Error != "" {
|
||||||
|
return []Device{{ID: CPU, Name: "CPU", Reason: c.Error}}
|
||||||
}
|
}
|
||||||
var out []Recipe
|
for _, g := range c.GPUs {
|
||||||
gpus := append([]GPU(nil), c.GPUs...)
|
d := Device{ID: g.Device, Name: g.Name, Integrated: g.Integrated}
|
||||||
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
|
if d.Name == "" {
|
||||||
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"}
|
d.Name = g.Device
|
||||||
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 {
|
||||||
switch codec {
|
case g.API != "vaapi":
|
||||||
case "h264":
|
d.Reason = "only VAAPI devices encode here"
|
||||||
video = append(video, "-profile:v", "high")
|
case !has(g.Encodes, "h264"):
|
||||||
case "hevc":
|
d.Reason = "doesn't encode H.264"
|
||||||
video = append(video, "-tag:v", "hvc1")
|
default:
|
||||||
|
d.OK = true
|
||||||
}
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
for _, g := range c.Unusable {
|
||||||
name := g.Name
|
name := g.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = g.Device
|
name = g.Device
|
||||||
}
|
}
|
||||||
scale := fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", w, h)
|
out = append(out, Device{ID: g.Device, Name: name, Integrated: g.Integrated, Reason: g.Reason})
|
||||||
if has(g.Codecs, src.Video.Codec) {
|
}
|
||||||
vf := scale
|
d := Device{ID: CPU, Name: "CPU (libx264)", OK: has(c.Encoders, "libx264")}
|
||||||
|
if !d.OK {
|
||||||
|
d.Reason = "this ffmpeg has no libx264"
|
||||||
|
}
|
||||||
|
return append(out, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipes are the ways to make quality r of src on device, best first: on a GPU, decoding on
|
||||||
|
// the same card (frames never leave it) before decoding in software and uploading; the CPU
|
||||||
|
// last, as the fallback whatever was asked. device "" is any: a card on the bus before the
|
||||||
|
// integrated GPU. An empty list means nothing here can.
|
||||||
|
func recipes(c Caps, device string, src Info, r Rung) []Recipe {
|
||||||
|
var out []Recipe
|
||||||
|
gpus := append([]GPU(nil), c.GPUs...)
|
||||||
|
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
|
||||||
|
kbps := r.Bitrate / 1000
|
||||||
|
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*3/2) + "k", "-bufsize", strconv.Itoa(kbps*2) + "k",
|
||||||
|
"-profile:v", "high", "-level", r.level}
|
||||||
|
fps := ""
|
||||||
|
if src.Video == nil || src.Video.FPS <= 0 || math.Abs(src.Video.FPS-r.FPS) > 0.01 {
|
||||||
|
fps = "fps=" + fpsArg(r.FPS) + ","
|
||||||
|
}
|
||||||
|
if device != CPU {
|
||||||
|
for i := range gpus {
|
||||||
|
g := &gpus[i]
|
||||||
|
if g.API != "vaapi" || !has(g.Encodes, "h264") || (device != "" && g.Device != device) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
video := append(append([]string{"-c:v", "h264_vaapi"}, rate...), "-bf", "0")
|
||||||
|
name := g.Name
|
||||||
|
if name == "" {
|
||||||
|
name = g.Device
|
||||||
|
}
|
||||||
|
scale := fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", r.Width, r.Height)
|
||||||
|
if src.Video != nil && has(g.Codecs, src.Video.Codec) {
|
||||||
|
vf := fps + scale
|
||||||
if src.HDR() {
|
if src.HDR() {
|
||||||
vf = "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + vf
|
vf = fps + "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + scale
|
||||||
}
|
}
|
||||||
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, Device: g.Device, 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"},
|
out = append(out, Recipe{Engine: name + ", decoded on the CPU", Device: g.Device, 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})
|
VF: fmt.Sprintf("%sformat=nv12,scale=%d:%d,hwupload", fps, r.Width, r.Height), Video: video})
|
||||||
}
|
}
|
||||||
swVF := fmt.Sprintf("scale=%d:%d,format=yuv420p", w, h)
|
}
|
||||||
|
if has(c.Encoders, "libx264") {
|
||||||
|
vf := fmt.Sprintf("%sscale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
|
||||||
if src.HDR() {
|
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)
|
vf = fmt.Sprintf("%szscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709,scale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
|
||||||
}
|
}
|
||||||
cap := []string{"-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"}
|
out = append(out, Recipe{Engine: "CPU (libx264)", Device: CPU, VF: vf, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-sc_threshold", "0"}, rate...)})
|
||||||
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// EngineFor is the engine the picker will say a codec runs on, "" when none can.
|
// Seg is the length of a piece, in seconds.
|
||||||
func EngineFor(c Caps, codec string) string {
|
const Seg = 3.0
|
||||||
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
|
// tsBase is added to every run's times, so pieces from different runs follow on: without it
|
||||||
// audio streams, the picture through the recipe, sound as AAC unless it already is, a
|
// a run from the top would start just below zero (the B-frame and AAC delay) and the muxer
|
||||||
// keyframe every segment, and an event playlist of fragmented-MP4 segments in dir.
|
// would shift that run alone.
|
||||||
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
|
const tsBase = 10
|
||||||
|
|
||||||
// segArgs is one run of a seekable re-encode: segments from..to-1 of the fixed 4 s grid.
|
// pieceArgs is one run making quality r from piece n on, into dir as <n>.ts: an accurate seek
|
||||||
// Every run is made the same way, the first included, so their segments are interchangeable:
|
// to n×Seg, a keyframe on every Seg mark and nowhere else (so every piece starts on one, and
|
||||||
// an accurate seek to from×4 s, timestamps put back where that is in the film (+ tsBase),
|
// pieces from any run and quality line up), times put back where that is in the film. in is a
|
||||||
// kept as they are in each fragment (frag_discont), numbered from `from`, and an end just
|
// path or, for a file on another machine, an http URL.
|
||||||
// short of segment `to`, which is already on disk, so the run never writes over it. Segments
|
func pieceArgs(dir, in string, src Info, rc Recipe, r Rung, n int) []string {
|
||||||
// land in dir as seg%05d.m4s; init is the init segment's name (only the first run's is used).
|
at := strconv.FormatFloat(float64(n)*Seg, 'f', 3, 64)
|
||||||
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 := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
|
||||||
a = append(a, r.Pre...)
|
a = append(a, rc.Pre...)
|
||||||
a = append(a, "-ss", strconv.Itoa(start), "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
|
if strings.HasPrefix(in, "http://") || strings.HasPrefix(in, "https://") {
|
||||||
if r.VF != "" {
|
a = append(a, "-reconnect", "1", "-reconnect_on_network_error", "1", "-reconnect_delay_max", "4")
|
||||||
a = append(a, "-vf", r.VF)
|
|
||||||
}
|
}
|
||||||
a = append(a, r.Video...)
|
a = append(a, "-ss", at, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
|
||||||
switch {
|
if rc.VF != "" {
|
||||||
case src.Audio == nil:
|
a = append(a, "-vf", rc.VF)
|
||||||
|
}
|
||||||
|
a = append(a, rc.Video...)
|
||||||
|
if src.Audio == nil {
|
||||||
a = append(a, "-an")
|
a = append(a, "-an")
|
||||||
case src.Audio.Codec == "aac":
|
} else {
|
||||||
a = append(a, "-c:a", "copy")
|
|
||||||
default:
|
|
||||||
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
|
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
|
||||||
}
|
}
|
||||||
gop := int(math.Round(fps * hlsSeconds))
|
gop := int(math.Ceil(r.FPS*Seg)) * 4
|
||||||
if gop < 24 {
|
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%g)", Seg),
|
||||||
gop = 24
|
"-output_ts_offset", strconv.FormatFloat(float64(n)*Seg+tsBase, 'f', 3, 64),
|
||||||
}
|
"-f", "hls", "-hls_time", strconv.FormatFloat(Seg-0.5, 'f', 2, 64), "-hls_list_size", "0",
|
||||||
if gop > 300 {
|
"-hls_flags", "temp_file", "-hls_segment_type", "mpegts", "-start_number", strconv.Itoa(n),
|
||||||
gop = 300
|
"-hls_segment_filename", dir+"/%d.ts", dir+"/ffmpeg.m3u8")
|
||||||
}
|
|
||||||
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
|
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 {
|
func has(list []string, x string) bool {
|
||||||
for _, v := range list {
|
for _, v := range list {
|
||||||
if v == x {
|
if v == x {
|
||||||
|
|||||||
+86
-106
@@ -9,25 +9,40 @@ func src(codec string, w, h int, fps float64) Info {
|
|||||||
return Info{Duration: 100, Video: &Track{Codec: codec, Width: w, Height: h, FPS: fps}, Audio: &Track{Codec: "opus", Channels: 2}}
|
return Info{Duration: 100, Video: &Track{Codec: codec, Width: w, Height: h, FPS: fps}, Audio: &Track{Codec: "opus", Channels: 2}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFit(t *testing.T) {
|
func TestLadder(t *testing.T) {
|
||||||
q := func(id string) Quality { x, _ := qualityByID(id); return x }
|
vr := src("hevc", 7680, 3840, 50)
|
||||||
for _, c := range []struct {
|
vr.Video.Spherical = true
|
||||||
q string
|
r := Ladder(vr)
|
||||||
in Info
|
if len(r) != 4 || r[0].Name != "3840" || r[0].Height != 1920 || r[0].level != "5.2" || r[3].FPS != 25 || r[0].Label != "2160s" {
|
||||||
codec string
|
t.Fatalf("8K 360: %+v", r)
|
||||||
w, h, kbs int
|
|
||||||
}{
|
|
||||||
{"1080p", src("hevc", 3840, 2160, 60), "h264", 1920, 1080, 10000},
|
|
||||||
{"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000},
|
|
||||||
{"720p", src("h264", 2292, 960, 60), "h264", 1718, 720, 5000}, // odd width made even
|
|
||||||
{"1080p", src("h264", 1280, 720, 30), "h264", 1280, 720, 4000}, // never upscaled
|
|
||||||
{"480p", src("h264", 1920, 1080, 30), "hevc", 852, 480, 1200}, // 853.3 rounds down, then even
|
|
||||||
{"original", src("av1", 640, 360, 24), "av1", 640, 360, 1200},
|
|
||||||
} {
|
|
||||||
w, h, k := fit(q(c.q), c.in, c.codec)
|
|
||||||
if w != c.w || h != c.h || k != c.kbs {
|
|
||||||
t.Errorf("%s %s %dx%d@%.0f → %dx%d %dk, want %dx%d %dk", c.q, c.codec, c.in.Video.Width, c.in.Video.Height, c.in.Video.FPS, w, h, k, c.w, c.h, c.kbs)
|
|
||||||
}
|
}
|
||||||
|
if r[0].codecs != "avc1.640034,mp4a.40.2" {
|
||||||
|
t.Fatalf("codecs %q", r[0].codecs)
|
||||||
|
}
|
||||||
|
r = Ladder(src("h264", 2292, 960, 60)) // a capture: its own size on top, then the steps under it
|
||||||
|
if len(r) != 3 || r[0].Label != "960p" || r[0].Width != 2292 || r[0].FPS != 60 || r[0].Bitrate != 5_000_000 || r[1].Label != "720p" || r[1].Width != 1720 || r[1].FPS != 30 {
|
||||||
|
t.Fatalf("2292×960: %+v", r)
|
||||||
|
}
|
||||||
|
r = Ladder(src("h264", 1920, 1080, 59.94))
|
||||||
|
if len(r) != 3 || r[0].Label != "1080p" || r[0].FPS != 59.94 || r[1].FPS != 29.97 || r[2].Width != 854 {
|
||||||
|
t.Fatalf("1080p: %+v", r)
|
||||||
|
}
|
||||||
|
if r = Ladder(src("h264", 1080, 1920, 30)); r[0].Width != 1080 || r[0].Height != 1920 { // standing up
|
||||||
|
t.Fatalf("portrait: %+v", r)
|
||||||
|
}
|
||||||
|
if r = Ladder(src("h264", 320, 240, 30)); len(r) != 1 || r[0].Width != 320 || r[0].Label != "240p" {
|
||||||
|
t.Fatalf("tiny: %+v", r)
|
||||||
|
}
|
||||||
|
silent := src("h264", 1280, 720, 30)
|
||||||
|
silent.Audio = nil
|
||||||
|
if r = Ladder(silent); strings.Contains(r[0].codecs, "mp4a") {
|
||||||
|
t.Fatalf("no sound, no AAC: %q", r[0].codecs)
|
||||||
|
}
|
||||||
|
if Ladder(Info{Duration: 10}) != nil {
|
||||||
|
t.Fatal("no picture, no ladder")
|
||||||
|
}
|
||||||
|
if fpsArg(29.97) != "30000/1001" || fpsArg(25) != "25" {
|
||||||
|
t.Fatal(fpsArg(29.97), fpsArg(25))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,111 +62,76 @@ func engineNames(r []Recipe) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecipesOrder(t *testing.T) {
|
func TestRecipes(t *testing.T) {
|
||||||
c := gridCaps()
|
c := gridCaps()
|
||||||
// h264 from hevc: the card first, decoding on it; then the iGPU the same way; then the CPU
|
in := src("hevc", 3840, 2160, 60)
|
||||||
got := engineNames(recipes(c, "h264", src("hevc", 3840, 2160, 60), 1920, 1080, 10000))
|
r1080 := Ladder(in)[2]
|
||||||
want := []string{"Intel Arc A380 (vaapi)", "Intel Arc A380 (vaapi, decoded on the CPU)", "Intel UHD Graphics 750 (vaapi)", "Intel UHD Graphics 750 (vaapi, decoded on the CPU)", "CPU (libx264)"}
|
// any device: the card first, decoding on it; then the iGPU the same way; then the CPU
|
||||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
got := strings.Join(engineNames(recipes(c, "", in, r1080)), "|")
|
||||||
t.Fatalf("got %v", got)
|
if got != "Intel Arc A380|Intel Arc A380, decoded on the CPU|Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||||||
|
t.Fatalf("any: %s", got)
|
||||||
}
|
}
|
||||||
// av1 source: neither card decodes it, so no same-card recipe, and only the Arc encodes av1
|
// the iGPU asked for: only it, then the CPU as the fallback
|
||||||
got = engineNames(recipes(c, "av1", src("av1", 1920, 1080, 30), 1920, 1080, 4800))
|
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD128", in, r1080)), "|"); got != "Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||||||
want = []string{"Intel Arc A380 (vaapi, decoded on the CPU)", "CPU (SVT-AV1)"}
|
t.Fatalf("iGPU: %s", got)
|
||||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
|
||||||
t.Fatalf("av1: got %v", got)
|
|
||||||
}
|
}
|
||||||
// copy is one recipe, no filter, hvc1 for hevc
|
if got = strings.Join(engineNames(recipes(c, CPU, in, r1080)), "|"); got != "CPU (libx264)" {
|
||||||
r := recipes(c, "copy", src("hevc", 1920, 1080, 30), 1920, 1080, 0)
|
t.Fatalf("cpu: %s", got)
|
||||||
if len(r) != 1 || r[0].VF != "" || !has(r[0].Video, "hvc1") || !has(r[0].Video, "copy") {
|
|
||||||
t.Fatalf("copy: %+v", r)
|
|
||||||
}
|
}
|
||||||
// a machine with no GPU and no x264: nothing for h264, something for av1
|
// an AV1 source: neither card decodes it, so no same-card recipe
|
||||||
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac", "libsvtav1"}}
|
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD129", src("av1", 1920, 1080, 30), r1080)), "|"); got != "Intel Arc A380, decoded on the CPU|CPU (libx264)" {
|
||||||
if len(recipes(bare, "h264", src("h264", 1280, 720, 30), 1280, 720, 4000)) != 0 {
|
t.Fatalf("av1: %s", got)
|
||||||
t.Fatal("no x264 means no h264")
|
|
||||||
}
|
}
|
||||||
if EngineFor(bare, "av1") != "CPU (SVT-AV1)" || EngineFor(bare, "h264") != "" || EngineFor(bare, "copy") != "Original codec" {
|
// a machine with no GPU and no x264: nothing
|
||||||
t.Fatal("engineFor")
|
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac"}}
|
||||||
|
if len(recipes(bare, "", in, r1080)) != 0 {
|
||||||
|
t.Fatal("no x264 means nothing")
|
||||||
}
|
}
|
||||||
if EngineFor(c, "hevc") != "Intel Arc A380 (vaapi)" {
|
// 30 fps from 60 for the lower rungs; HDR tone-mapped on both paths
|
||||||
t.Fatalf("engineFor on grid: %q", EngineFor(c, "hevc"))
|
r720 := Ladder(in)[3]
|
||||||
}
|
|
||||||
// HDR gets tone-mapped on both paths
|
|
||||||
hdr := src("hevc", 3840, 2160, 60)
|
hdr := src("hevc", 3840, 2160, 60)
|
||||||
hdr.Video.ColorTransfer = "smpte2084"
|
hdr.Video.ColorTransfer = "smpte2084"
|
||||||
r = recipes(c, "h264", hdr, 1920, 1080, 10000)
|
rs := recipes(c, "", hdr, r720)
|
||||||
if !strings.HasPrefix(r[0].VF, "tonemap_vaapi") || !strings.Contains(r[len(r)-1].VF, "tonemap=hable") {
|
if !strings.HasPrefix(rs[0].VF, "fps=30,tonemap_vaapi") || !strings.Contains(rs[len(rs)-1].VF, "tonemap=hable") {
|
||||||
t.Fatalf("hdr: %q … %q", r[0].VF, r[len(r)-1].VF)
|
t.Fatalf("hdr: %q … %q", rs[0].VF, rs[len(rs)-1].VF)
|
||||||
|
}
|
||||||
|
if rs := recipes(c, "", in, r1080); strings.Contains(rs[0].VF, "fps=") {
|
||||||
|
t.Fatalf("same rate needs no fps filter: %q", rs[0].VF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHLSArgs(t *testing.T) {
|
func TestDevices(t *testing.T) {
|
||||||
|
c := gridCaps()
|
||||||
|
c.Unusable = []GPU{{Device: "/dev/dri/renderD130", Name: "Something", Reason: "permission denied"}}
|
||||||
|
d := Devices(c)
|
||||||
|
if len(d) != 4 || !d[0].OK || !d[1].OK || d[2].OK || d[2].Reason == "" || d[3].ID != CPU || !d[3].OK {
|
||||||
|
t.Fatalf("%+v", d)
|
||||||
|
}
|
||||||
|
if d := Devices(Caps{Error: "ffmpeg is not installed"}); len(d) != 1 || d[0].OK {
|
||||||
|
t.Fatalf("no ffmpeg: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPieceArgs(t *testing.T) {
|
||||||
in := src("h264", 1920, 1080, 60)
|
in := src("h264", 1920, 1080, 60)
|
||||||
r := recipes(gridCaps(), "h264", in, 1920, 1080, 10000)[0]
|
r := Ladder(in)[0]
|
||||||
a := strings.Join(hlsArgs("/d", "/v.mkv", in, r, false), " ")
|
rc := recipes(gridCaps(), "/dev/dri/renderD129", in, r)[0]
|
||||||
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-i /v.mkv", "-map 0:v:0 -map 0:a:0?", "-vf scale_vaapi=w=1920:h=1080:format=nv12", "-c:v h264_vaapi -b:v 10000k -maxrate 12500k", "-c:a aac -b:a 160k -ac 2", "-g 240 -force_key_frames expr:gte(t,n_forced*4)", "-hls_segment_type fmp4", "-hls_segment_filename /d/seg%05d.m4s", "/d/index.m3u8"} {
|
a := strings.Join(pieceArgs("/d", "/v.mkv", in, rc, r, 12), " ")
|
||||||
|
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-ss 36.000 -i /v.mkv", "-map 0:v:0 -map 0:a:0?",
|
||||||
|
"-vf scale_vaapi=w=1920:h=1080:format=nv12", "-c:v h264_vaapi -b:v 5000k -maxrate 7500k", "-level 4.2", "-c:a aac -b:a 160k -ac 2",
|
||||||
|
"-force_key_frames expr:gte(t,n_forced*3)", "-output_ts_offset 46.000", "-hls_time 2.50", "-start_number 12", "-hls_segment_filename /d/%d.ts"} {
|
||||||
if !strings.Contains(a, want) {
|
if !strings.Contains(a, want) {
|
||||||
t.Errorf("missing %q in\n%s", want, a)
|
t.Errorf("missing %q in\n%s", want, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// aac stays as it is; no audio means none; copy sets no keyframes
|
if strings.Contains(a, "-reconnect") {
|
||||||
in.Audio.Codec = "aac"
|
t.Error("a path needs no reconnect")
|
||||||
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, "-c:a copy") {
|
}
|
||||||
t.Error("aac should be copied")
|
if a := strings.Join(pieceArgs("/d", "http://10.0.0.234:8100/api/play-src/x", in, rc, r, 0), " "); !strings.Contains(a, "-reconnect 1") {
|
||||||
|
t.Error("a URL reconnects")
|
||||||
}
|
}
|
||||||
in.Audio = nil
|
in.Audio = nil
|
||||||
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, " -an ") {
|
if a := strings.Join(pieceArgs("/d", "/v", in, rc, r, 0), " "); !strings.Contains(a, "-an") {
|
||||||
t.Error("no audio stream → -an")
|
t.Error("no sound means -an")
|
||||||
}
|
|
||||||
cp := recipes(gridCaps(), "copy", in, 1920, 1080, 0)[0]
|
|
||||||
if a := strings.Join(hlsArgs("/d", "/v", in, cp, true), " "); strings.Contains(a, "force_key_frames") || strings.Contains(a, "-vf") {
|
|
||||||
t.Error("copy must not ask for keyframes or filter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSegArgs(t *testing.T) {
|
|
||||||
in := src("h264", 1920, 1080, 25)
|
|
||||||
r := recipes(gridCaps(), "h264", in, 1920, 1080, 10000)[0]
|
|
||||||
first := strings.Join(segArgs("/d", "/v.mkv", in, r, 0, 900, 900, "init.mp4"), " ")
|
|
||||||
for _, want := range []string{"-ss 0 -i /v.mkv", "-output_ts_offset 10", "-start_number 0", "movflags=+frag_discont", "-hls_fmp4_init_filename init.mp4", "-hls_segment_filename /d/seg%05d.m4s", "/d/run.m3u8", "-force_key_frames expr:gte(t,n_forced*4)"} {
|
|
||||||
if !strings.Contains(first, want) {
|
|
||||||
t.Errorf("first run: missing %q in\n%s", want, first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(first, " -t ") {
|
|
||||||
t.Error("a run to the end has no -t")
|
|
||||||
}
|
|
||||||
// a seek: segments 30..44, stopping half a frame short of 45, which is on disk
|
|
||||||
seek := strings.Join(segArgs("/d", "/v.mkv", in, r, 30, 45, 900, "init-run.mp4"), " ")
|
|
||||||
for _, want := range []string{"-ss 120 -i /v.mkv", "-t 59.980", "-output_ts_offset 130", "-start_number 30", "-hls_fmp4_init_filename init-run.mp4"} {
|
|
||||||
if !strings.Contains(seek, want) {
|
|
||||||
t.Errorf("seek run: missing %q in\n%s", want, seek)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Index(seek, "-ss 120") > strings.Index(seek, "-i /v.mkv") || strings.Index(seek, "-hwaccel") > strings.Index(seek, "-i /v.mkv") {
|
|
||||||
t.Error("-ss and the decoder's options go before -i")
|
|
||||||
}
|
|
||||||
for d, n := range map[float64]int{9: 3, 8.2: 2, 8.31: 3, 64: 16, 0.1: 1, 3600: 900} {
|
|
||||||
if got := segCount(d); got != n {
|
|
||||||
t.Errorf("segCount(%v) = %d, want %d", d, got, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseProgress(t *testing.T) {
|
|
||||||
b := []byte("frame=10\nout_time_us=1000000\nspeed=2.5x\nprogress=continue\nframe=20\nout_time_us=2500000\nspeed=3.1x\nprogress=continue\n")
|
|
||||||
s, sp := parseProgress(b)
|
|
||||||
if s != 2.5 || sp != 3.1 {
|
|
||||||
t.Fatalf("got %v %v", s, sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidChoice(t *testing.T) {
|
|
||||||
if err := ValidChoice("h264", "720p"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ValidChoice("vp8", "720p") == nil || ValidChoice("h264", "9p") == nil || ValidChoice("", "") == nil {
|
|
||||||
t.Fatal("bad choices must be refused")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-7
@@ -6,13 +6,13 @@ package media
|
|||||||
// transcode never outlives the console that asked for it.
|
// transcode never outlives the console that asked for it.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -92,6 +92,35 @@ func (p *ffproc) end() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pause stops ffmpeg where it is (SIGSTOP), and resume lets it go on: a stream far enough
|
||||||
|
// ahead of its player waits rather than making the rest of the film. A root run is paused by
|
||||||
|
// its helper, which is told on its standard input.
|
||||||
|
func (p *ffproc) pause() error { return p.signal(syscall.SIGSTOP, "stop\n") }
|
||||||
|
func (p *ffproc) resume() error { return p.signal(syscall.SIGCONT, "cont\n") }
|
||||||
|
|
||||||
|
func (p *ffproc) signal(sig syscall.Signal, word string) error {
|
||||||
|
if done, _ := p.exited(); done {
|
||||||
|
return errors.New("ended")
|
||||||
|
}
|
||||||
|
if p.in != nil {
|
||||||
|
_, err := io.WriteString(p.in, word)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return syscall.Kill(-p.cmd.Process.Pid, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kill ends ffmpeg now, without waiting: a paused one too.
|
||||||
|
func (p *ffproc) kill() {
|
||||||
|
if done, _ := p.exited(); done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.in != nil {
|
||||||
|
p.in.Close() // the helper ends it, waking it first if it is paused
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
// lastWords is what ffmpeg said last, for the record.
|
// lastWords is what ffmpeg said last, for the record.
|
||||||
func (p *ffproc) lastWords() string { return p.tail.last() }
|
func (p *ffproc) lastWords() string { return p.tail.last() }
|
||||||
|
|
||||||
@@ -141,7 +170,17 @@ func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = io.Copy(io.Discard, stdin) // returns when the daemon lets go
|
// "stop" and "cont" pause and resume it; the end of input (the daemon letting go)
|
||||||
|
// ends it.
|
||||||
|
sc := bufio.NewScanner(stdin)
|
||||||
|
for sc.Scan() {
|
||||||
|
switch sc.Text() {
|
||||||
|
case "stop":
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGSTOP)
|
||||||
|
case "cont":
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGCONT)
|
||||||
|
}
|
||||||
|
}
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
@@ -151,6 +190,7 @@ func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
|
|||||||
case werr = <-done:
|
case werr = <-done:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGCONT) // a paused one can't act on TERM
|
||||||
select {
|
select {
|
||||||
case werr = <-done:
|
case werr = <-done:
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
@@ -181,8 +221,3 @@ func clearDir(dir string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasSegments(playlist string) bool {
|
|
||||||
b, err := os.ReadFile(playlist)
|
|
||||||
return err == nil && strings.Contains(string(b), "#EXTINF")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,669 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
// Playing what the browser cannot play as it is: an HLS stream, re-encoded on demand by
|
|
||||||
// ffmpeg on the machine's own GPU where it has one it can use, on its CPU otherwise. A
|
|
||||||
// stream is one file at one codec and size, in a folder of its own under
|
|
||||||
// <data dir>/transcode/.
|
|
||||||
//
|
|
||||||
// A re-encode is seekable anywhere from the start. Its playlist is written up front, for
|
|
||||||
// the whole film: a fixed grid of 4 s segments, so the player's timeline is the film's.
|
|
||||||
// ffmpeg runs ahead from the top as fast as it can; a request for a segment it hasn't
|
|
||||||
// reached and won't reach soon — a seek ahead, or back into a stretch never made — ends that
|
|
||||||
// run and starts another at the segment asked for, which stops on its own where segments
|
|
||||||
// already exist. When a run finishes, the next gap is filled, until the whole file is on
|
|
||||||
// disk. Every run is made the same way (segArgs), so segments from different runs join.
|
|
||||||
//
|
|
||||||
// "Repackaged" (copy) can't cut on a fixed grid — its keyframes are the source's — so it is
|
|
||||||
// an event playlist that grows as ffmpeg writes it, seekable within what's done. Repackaging
|
|
||||||
// runs at disk speed, so that is all of it within seconds.
|
|
||||||
//
|
|
||||||
// Nothing is kept: a stream nobody has touched for a while is ended and its folder removed,
|
|
||||||
// and the folder is emptied when the daemon starts.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Stream is one re-encode, in progress or finished.
|
|
||||||
type Stream struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Codec string `json:"codec"`
|
|
||||||
Quality string `json:"quality"`
|
|
||||||
Engine string `json:"engine"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
Kbps int `json:"kbps"`
|
|
||||||
Source Info `json:"source"`
|
|
||||||
Started time.Time `json:"started"`
|
|
||||||
Root bool `json:"root"`
|
|
||||||
Seekable bool `json:"seekable"` // seeking anywhere restarts the re-encode there
|
|
||||||
|
|
||||||
key string
|
|
||||||
dir string
|
|
||||||
mu sync.Mutex
|
|
||||||
used time.Time
|
|
||||||
proc *ffproc // the run going now (or the only one, for copy)
|
|
||||||
finished bool
|
|
||||||
failure string
|
|
||||||
gone bool // forgotten by the Streamer
|
|
||||||
|
|
||||||
// seekable streams only
|
|
||||||
caps Caps
|
|
||||||
self string
|
|
||||||
recipe Recipe
|
|
||||||
n int // segments in the playlist
|
|
||||||
end int // segments past this are not in the file after all (n until a run finds out)
|
|
||||||
run *segRun
|
|
||||||
runMu sync.Mutex // one run change at a time; held across ending one run and starting the next
|
|
||||||
asked time.Time // when the request that last moved the run arrived: an older one never moves it back
|
|
||||||
initOK bool // a later run's init segment was checked against the first's
|
|
||||||
initBad bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// segRun is one ffmpeg over segments from..to-1.
|
|
||||||
type segRun struct {
|
|
||||||
from, to int
|
|
||||||
proc *ffproc
|
|
||||||
stopped bool // ended by us: what it wrote after that is not to be trusted
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamStatus is what the player asks for while the file is still going through.
|
|
||||||
type StreamStatus struct {
|
|
||||||
Running bool `json:"running"`
|
|
||||||
Done bool `json:"done"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Progress float64 `json:"progress"` // seconds of output written (copy), or on disk (seekable)
|
|
||||||
Speed float64 `json:"speed"` // × realtime
|
|
||||||
Segments int `json:"segments"`
|
|
||||||
From float64 `json:"from"` // seekable: where the run going now started, seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streamer keeps one machine's streams.
|
|
||||||
type Streamer struct {
|
|
||||||
dir string
|
|
||||||
self string
|
|
||||||
limit int
|
|
||||||
mu sync.Mutex
|
|
||||||
streams map[string]*Stream
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamIdle = 10 * time.Minute
|
|
||||||
const firstSegmentWait = 40 * time.Second
|
|
||||||
|
|
||||||
// segmentWait is how long a request for a segment waits for a run to write it.
|
|
||||||
const segmentWait = 45 * time.Second // under the hub proxy's 60 s wait for a node to answer
|
|
||||||
|
|
||||||
// lookahead: a segment this many past what the run has written is waited for, not
|
|
||||||
// restarted at. A few seconds of film, which any engine here writes faster than that.
|
|
||||||
const lookahead = 3
|
|
||||||
|
|
||||||
var streamFile = regexp.MustCompile(`^(index\.m3u8|init\.mp4|seg\d{5}\.m4s)$`)
|
|
||||||
var segName = regexp.MustCompile(`^seg(\d{5})\.m4s$`)
|
|
||||||
|
|
||||||
// NewStreamer empties the folder — a daemon restart orphans every stream — and starts the
|
|
||||||
// sweeper.
|
|
||||||
func NewStreamer(dataDir, self string, limit int) *Streamer {
|
|
||||||
m := &Streamer{dir: filepath.Join(dataDir, "transcode"), self: self, limit: limit, streams: map[string]*Stream{}}
|
|
||||||
_ = os.MkdirAll(m.dir, 0o755)
|
|
||||||
_ = clearDir(m.dir)
|
|
||||||
go func() {
|
|
||||||
for range time.Tick(30 * time.Second) {
|
|
||||||
m.Sweep(streamIdle)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open starts a stream, or hands back the one already serving the same request. It
|
|
||||||
// returns once the first segment is on disk, so the player's first fetch is answered.
|
|
||||||
func (m *Streamer) Open(ctx context.Context, c Caps, root bool, path, codec, quality string, src Info) (*Stream, error) {
|
|
||||||
if c.Error != "" {
|
|
||||||
return nil, errors.New(c.Error)
|
|
||||||
}
|
|
||||||
if err := ValidChoice(codec, quality); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
q, _ := qualityByID(quality)
|
|
||||||
if src.Video == nil {
|
|
||||||
return nil, &DecodeError{Msg: "no video stream"}
|
|
||||||
}
|
|
||||||
if src.Video.Width <= 0 || src.Video.Height <= 0 {
|
|
||||||
return nil, &DecodeError{Msg: "the picture's size is unknown"}
|
|
||||||
}
|
|
||||||
key := fmt.Sprintf("%s|%d|%.0f|%s|%s|%v", path, src.Size, src.Duration, codec, quality, root)
|
|
||||||
m.mu.Lock()
|
|
||||||
for _, s := range m.streams {
|
|
||||||
if s.key == key {
|
|
||||||
if s.failed() { // not the stream to hand back: let it go and make a fresh one
|
|
||||||
delete(m.streams, s.ID)
|
|
||||||
_ = os.RemoveAll(s.dir)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
s.touch()
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
running := 0
|
|
||||||
for _, s := range m.streams {
|
|
||||||
if s.running() {
|
|
||||||
running++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if running >= m.limit {
|
|
||||||
m.mu.Unlock()
|
|
||||||
return nil, fmt.Errorf("%d videos are already being re-encoded on this machine; one must finish or be closed first", running)
|
|
||||||
}
|
|
||||||
s := &Stream{ID: newID(), Path: path, Codec: codec, Quality: quality, Source: src, Started: time.Now(), Root: root, key: key, used: time.Now(),
|
|
||||||
caps: c, self: m.self}
|
|
||||||
s.dir = filepath.Join(m.dir, s.ID)
|
|
||||||
// A fixed grid needs the film's length; without one it can only grow as it goes.
|
|
||||||
s.Seekable = codec != "copy" && src.Duration > 0
|
|
||||||
if s.Seekable {
|
|
||||||
s.n = segCount(src.Duration)
|
|
||||||
s.end = s.n
|
|
||||||
}
|
|
||||||
m.streams[s.ID] = s
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
s.Width, s.Height, s.Kbps = fit(q, src, codec)
|
|
||||||
if codec == "copy" {
|
|
||||||
s.Width, s.Height, s.Kbps = src.Video.Width, src.Video.Height, int(src.BitRate/1000)
|
|
||||||
}
|
|
||||||
rs := recipes(c, codec, src, s.Width, s.Height, s.Kbps)
|
|
||||||
if len(rs) == 0 {
|
|
||||||
m.forget(s)
|
|
||||||
return nil, errors.New("nothing on this machine encodes " + codec)
|
|
||||||
}
|
|
||||||
var last string
|
|
||||||
for _, r := range rs {
|
|
||||||
if err := os.MkdirAll(s.dir, 0o755); err != nil { // the root helper's ffmpeg writes here too
|
|
||||||
m.forget(s)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_ = clearDir(s.dir)
|
|
||||||
var args []string
|
|
||||||
if s.Seekable {
|
|
||||||
args = segArgs(s.dir, path, src, r, 0, s.n, s.n, "init.mp4")
|
|
||||||
} else {
|
|
||||||
args = hlsArgs(s.dir, path, src, r, codec == "copy")
|
|
||||||
}
|
|
||||||
p, err := startFF(c, m.self, root, args)
|
|
||||||
if err != nil {
|
|
||||||
last = r.Engine + ": " + err.Error()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.proc, s.Engine, s.recipe = p, r.Engine, r
|
|
||||||
if s.Seekable {
|
|
||||||
s.run = &segRun{from: 0, to: s.n, proc: p}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
msg, ok := s.waitFirstSegment(ctx)
|
|
||||||
if ok {
|
|
||||||
if s.Seekable {
|
|
||||||
go s.watchRun(s.run)
|
|
||||||
} else {
|
|
||||||
go s.watch()
|
|
||||||
}
|
|
||||||
log.Printf("media: stream %s: %s → %s %s %dx%d %dk on %s", s.ID, filepath.Base(path), codec, quality, s.Width, s.Height, s.Kbps, r.Engine)
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
p.end()
|
|
||||||
if ctx.Err() != nil || s.closed() { // the caller left, or somebody closed it meanwhile: not the recipe's fault
|
|
||||||
m.forget(s)
|
|
||||||
return nil, errors.New("cancelled")
|
|
||||||
}
|
|
||||||
last = r.Engine + ": " + msg
|
|
||||||
log.Printf("media: stream %s on %s: %s", filepath.Base(path), r.Engine, msg)
|
|
||||||
}
|
|
||||||
m.forget(s)
|
|
||||||
return nil, &DecodeError{Msg: last}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitFirstSegment watches for the first segment, or ffmpeg giving up.
|
|
||||||
func (s *Stream) waitFirstSegment(ctx context.Context) (string, bool) {
|
|
||||||
deadline := time.Now().Add(firstSegmentWait)
|
|
||||||
playlist := filepath.Join(s.dir, "index.m3u8")
|
|
||||||
for {
|
|
||||||
if s.Seekable && exists(s.segPath(0)) || !s.Seekable && hasSegments(playlist) {
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
if done, err := s.proc.exited(); done {
|
|
||||||
msg := s.proc.lastWords()
|
|
||||||
if msg == "" && err != nil {
|
|
||||||
msg = err.Error()
|
|
||||||
}
|
|
||||||
if msg == "" {
|
|
||||||
msg = "ffmpeg ended before writing a segment"
|
|
||||||
}
|
|
||||||
return msg, false
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "no segment after " + firstSegmentWait.String(), false
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err().Error(), false
|
|
||||||
case <-time.After(250 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// watch records how the encode ended (copy streams: one run, start to end).
|
|
||||||
func (s *Stream) watch() {
|
|
||||||
<-s.proc.done
|
|
||||||
s.mu.Lock()
|
|
||||||
s.finished = true
|
|
||||||
if s.proc.err != nil {
|
|
||||||
s.failure = s.proc.lastWords()
|
|
||||||
if s.failure == "" {
|
|
||||||
s.failure = s.proc.err.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
if s.failure != "" {
|
|
||||||
log.Printf("media: stream %s ended early: %s", s.ID, s.failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- seekable streams ----
|
|
||||||
|
|
||||||
func (s *Stream) segPath(i int) string { return filepath.Join(s.dir, fmt.Sprintf("seg%05d.m4s", i)) }
|
|
||||||
|
|
||||||
// written is the last segment of a run on disk, counting on from where it started.
|
|
||||||
func (s *Stream) written(r *segRun) int {
|
|
||||||
i := r.from
|
|
||||||
for i < r.to && exists(s.segPath(i)) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return i - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchRun follows one run to its end. A run that ends by itself — the end of the film, or
|
|
||||||
// the segments it was to stop short of — is followed by the next gap. One that failed fails
|
|
||||||
// the stream; one we ended is the business of whoever ended it.
|
|
||||||
func (s *Stream) watchRun(r *segRun) {
|
|
||||||
<-r.proc.done
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.gone || r.stopped || s.run != r {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.proc.err != nil {
|
|
||||||
s.failure = r.proc.lastWords()
|
|
||||||
if s.failure == "" {
|
|
||||||
s.failure = r.proc.err.Error()
|
|
||||||
}
|
|
||||||
s.finished = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
log.Printf("media: stream %s: run from segment %d failed: %s", s.ID, r.from, s.failure)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if w := s.written(r); w+1 < r.to && r.to == s.n {
|
|
||||||
// ran to the end of the file before the end of the grid: the picture is shorter than
|
|
||||||
// the container says, and those last segments will never exist
|
|
||||||
s.end = w + 1
|
|
||||||
log.Printf("media: stream %s: the film ends at segment %d of %d", s.ID, s.end, s.n)
|
|
||||||
}
|
|
||||||
s.checkInit(r)
|
|
||||||
s.mu.Unlock()
|
|
||||||
s.fillNext(r.to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkInit compares a later run's init segment with the first run's, once. They come out
|
|
||||||
// identical (same encoder, same settings); if one day they don't, the log says so.
|
|
||||||
func (s *Stream) checkInit(r *segRun) {
|
|
||||||
if r.from == 0 || s.initOK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a, err1 := os.ReadFile(filepath.Join(s.dir, "init.mp4"))
|
|
||||||
b, err2 := os.ReadFile(filepath.Join(s.dir, "init-run.mp4"))
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.initOK = true
|
|
||||||
if !bytes.Equal(a, b) && !s.initBad {
|
|
||||||
s.initBad = true
|
|
||||||
log.Printf("media: stream %s: a restarted run's init segment differs from the first (%s); its segments may not join", s.ID, s.Engine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// missingFrom is the first segment not on disk at or after i (wrapping round to the start),
|
|
||||||
// or -1 when the whole film is there.
|
|
||||||
func (s *Stream) missingFrom(i int) int {
|
|
||||||
end := s.end
|
|
||||||
for k := 0; k < end; k++ {
|
|
||||||
j := (i + k) % end
|
|
||||||
if !exists(s.segPath(j)) {
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillNext starts a run at the next gap after `after`; when there is none, the stream is done.
|
|
||||||
func (s *Stream) fillNext(after int) {
|
|
||||||
s.runMu.Lock()
|
|
||||||
defer s.runMu.Unlock()
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.gone || s.failure != "" || (s.run != nil && !s.run.done()) {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
end := s.end
|
|
||||||
s.mu.Unlock()
|
|
||||||
if after >= end {
|
|
||||||
after = 0
|
|
||||||
}
|
|
||||||
next := s.missingFrom(after)
|
|
||||||
if next < 0 {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.finished, s.proc = true, nil
|
|
||||||
s.mu.Unlock()
|
|
||||||
log.Printf("media: stream %s: all %d segments on disk", s.ID, end)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.startRun(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *segRun) done() bool { d, _ := r.proc.exited(); return d }
|
|
||||||
|
|
||||||
// startRun ends the run going now and starts one at segment from, stopping short of the next
|
|
||||||
// segment already on disk. The run it ends is asked to stop and waited for, and whatever it
|
|
||||||
// finished writing after being asked is removed: ffmpeg closes the segment it was in the
|
|
||||||
// middle of on its way out, short. Called with runMu held, mu not.
|
|
||||||
func (s *Stream) startRun(from int) {
|
|
||||||
s.mu.Lock()
|
|
||||||
old := s.run
|
|
||||||
end := s.end
|
|
||||||
s.mu.Unlock()
|
|
||||||
if old != nil && !old.done() {
|
|
||||||
had := map[int]bool{}
|
|
||||||
for i := old.from; i < old.to; i++ {
|
|
||||||
had[i] = exists(s.segPath(i))
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
old.stopped = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
old.proc.end()
|
|
||||||
for i := old.from; i < old.to; i++ {
|
|
||||||
if !had[i] && exists(s.segPath(i)) {
|
|
||||||
_ = os.Remove(s.segPath(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
to := from + 1
|
|
||||||
for to < end && !exists(s.segPath(to)) {
|
|
||||||
to++
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.gone {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
init := "init-run.mp4"
|
|
||||||
if !exists(filepath.Join(s.dir, "init.mp4")) {
|
|
||||||
init = "init.mp4"
|
|
||||||
}
|
|
||||||
p, err := startFF(s.caps, s.self, s.Root, segArgs(s.dir, s.Path, s.Source, s.recipe, from, to, s.n, init))
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
s.failure, s.finished = "restarting at "+strconv.Itoa(from*hlsSeconds)+" s: "+err.Error(), true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r := &segRun{from: from, to: to, proc: p}
|
|
||||||
s.run, s.proc, s.finished = r, p, false
|
|
||||||
go s.watchRun(r)
|
|
||||||
log.Printf("media: stream %s: run from %d s to %d s", s.ID, from*hlsSeconds, to*hlsSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playlist is the whole film's, written before most of it exists.
|
|
||||||
func (s *Stream) Playlist() []byte {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("#EXTM3U\n#EXT-X-VERSION:7\n")
|
|
||||||
fmt.Fprintf(&b, "#EXT-X-TARGETDURATION:%d\n", hlsSeconds)
|
|
||||||
b.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MAP:URI=\"init.mp4\"\n")
|
|
||||||
for i := 0; i < s.n; i++ {
|
|
||||||
d := float64(hlsSeconds)
|
|
||||||
if i == s.n-1 {
|
|
||||||
d = s.Source.Duration - float64(i*hlsSeconds)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "#EXTINF:%.6f,\nseg%05d.m4s\n", d, i)
|
|
||||||
}
|
|
||||||
b.WriteString("#EXT-X-ENDLIST\n")
|
|
||||||
return []byte(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrNotInFilm is a segment the playlist lists but the film turned out not to reach.
|
|
||||||
var ErrNotInFilm = errors.New("past the end of the film")
|
|
||||||
|
|
||||||
// Segment is the path of segment i once it is on disk. When the run going now will write it
|
|
||||||
// soon, it waits; otherwise this is a seek, and the run moves there — unless a newer request
|
|
||||||
// has moved it since this one arrived.
|
|
||||||
func (s *Stream) Segment(ctx context.Context, i int) (string, error) {
|
|
||||||
asked := time.Now()
|
|
||||||
deadline := asked.Add(segmentWait)
|
|
||||||
p := s.segPath(i)
|
|
||||||
for {
|
|
||||||
s.touch()
|
|
||||||
if exists(p) {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
switch {
|
|
||||||
case s.gone:
|
|
||||||
s.mu.Unlock()
|
|
||||||
return "", errors.New("the stream was closed")
|
|
||||||
case i >= s.end:
|
|
||||||
s.mu.Unlock()
|
|
||||||
return "", ErrNotInFilm
|
|
||||||
case s.failure != "":
|
|
||||||
f := s.failure
|
|
||||||
s.mu.Unlock()
|
|
||||||
return "", errors.New(f)
|
|
||||||
}
|
|
||||||
r := s.run
|
|
||||||
coming := r != nil && !r.done() && r.from <= i && i < r.to && i <= s.written(r)+lookahead
|
|
||||||
move := !coming && !asked.Before(s.asked)
|
|
||||||
if move {
|
|
||||||
s.asked = asked
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
if move {
|
|
||||||
s.runMu.Lock()
|
|
||||||
// still the newest, and still not on its way? (a run may have moved for a
|
|
||||||
// newer request while this one waited for the lock)
|
|
||||||
s.mu.Lock()
|
|
||||||
r = s.run
|
|
||||||
coming = r != nil && !r.done() && r.from <= i && i < r.to && i <= s.written(r)+lookahead
|
|
||||||
newest := !asked.Before(s.asked)
|
|
||||||
s.mu.Unlock()
|
|
||||||
if !coming && newest && !exists(p) {
|
|
||||||
log.Printf("media: stream %s: seek to %d s", s.ID, i*hlsSeconds)
|
|
||||||
s.startRun(i)
|
|
||||||
}
|
|
||||||
s.runMu.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "", fmt.Errorf("segment %d didn't arrive in %s", i, segmentWait)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-time.After(150 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SegmentIndex reads the number out of a segment's name.
|
|
||||||
func SegmentIndex(name string) (int, bool) {
|
|
||||||
m := segName.FindStringSubmatch(name)
|
|
||||||
if m == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
i, err := strconv.Atoi(m[1])
|
|
||||||
return i, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- both kinds ----
|
|
||||||
|
|
||||||
func (m *Streamer) Find(id string) (*Stream, bool) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
s, ok := m.streams[id]
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close ends a stream and removes its folder.
|
|
||||||
func (m *Streamer) Close(id string) {
|
|
||||||
m.mu.Lock()
|
|
||||||
s, ok := m.streams[id]
|
|
||||||
m.mu.Unlock()
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.gone = true // a run ending now is ours, not a gap to fill
|
|
||||||
p := s.proc
|
|
||||||
if s.run != nil {
|
|
||||||
s.run.stopped = true
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
if p != nil {
|
|
||||||
p.end()
|
|
||||||
}
|
|
||||||
m.forget(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Streamer) forget(s *Stream) {
|
|
||||||
m.mu.Lock()
|
|
||||||
delete(m.streams, s.ID)
|
|
||||||
m.mu.Unlock()
|
|
||||||
s.mu.Lock()
|
|
||||||
s.gone = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
_ = os.RemoveAll(s.dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Stream) closed() bool { s.mu.Lock(); defer s.mu.Unlock(); return s.gone }
|
|
||||||
|
|
||||||
func (s *Stream) failed() bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.finished && s.failure != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep closes what nobody has touched for a while.
|
|
||||||
func (m *Streamer) Sweep(idle time.Duration) {
|
|
||||||
m.mu.Lock()
|
|
||||||
var old []string
|
|
||||||
for id, s := range m.streams {
|
|
||||||
s.mu.Lock()
|
|
||||||
if time.Since(s.used) > idle {
|
|
||||||
old = append(old, id)
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
for _, id := range old {
|
|
||||||
log.Printf("media: stream %s idle, closed", id)
|
|
||||||
m.Close(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// File is the on-disk path of one of the stream's files — the playlist (copy streams; a
|
|
||||||
// seekable one's is Playlist), the init segment or a media segment — and nothing else.
|
|
||||||
func (s *Stream) File(name string) (string, bool) {
|
|
||||||
if !streamFile.MatchString(name) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
s.touch()
|
|
||||||
return filepath.Join(s.dir, name), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Stream) touch() { s.mu.Lock(); s.used = time.Now(); s.mu.Unlock() }
|
|
||||||
|
|
||||||
func (s *Stream) running() bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.proc != nil && !s.finished
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status is read from ffmpeg's own progress file and what is on disk.
|
|
||||||
func (s *Stream) Status() StreamStatus {
|
|
||||||
s.mu.Lock()
|
|
||||||
st := StreamStatus{Running: s.proc != nil && !s.finished, Done: s.finished && s.failure == "", Error: s.failure}
|
|
||||||
seekable, end, run := s.Seekable, s.end, s.run
|
|
||||||
s.mu.Unlock()
|
|
||||||
b, err := os.ReadFile(filepath.Join(s.dir, "progress"))
|
|
||||||
if err == nil {
|
|
||||||
st.Progress, st.Speed = parseProgress(b)
|
|
||||||
}
|
|
||||||
if !seekable {
|
|
||||||
if b, err := os.ReadFile(filepath.Join(s.dir, "index.m3u8")); err == nil {
|
|
||||||
st.Segments = strings.Count(string(b), "#EXTINF")
|
|
||||||
}
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
for i := 0; i < end; i++ {
|
|
||||||
if exists(s.segPath(i)) {
|
|
||||||
st.Segments++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.Progress = float64(st.Segments * hlsSeconds)
|
|
||||||
if st.Progress > s.Source.Duration {
|
|
||||||
st.Progress = s.Source.Duration
|
|
||||||
}
|
|
||||||
if run != nil && st.Running {
|
|
||||||
st.From = float64(run.from * hlsSeconds)
|
|
||||||
}
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseProgress takes the last out_time_us and speed from ffmpeg's -progress output.
|
|
||||||
func parseProgress(b []byte) (seconds, speed float64) {
|
|
||||||
sc := bufio.NewScanner(strings.NewReader(string(b)))
|
|
||||||
for sc.Scan() {
|
|
||||||
k, v, ok := strings.Cut(sc.Text(), "=")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
switch k {
|
|
||||||
case "out_time_us":
|
|
||||||
if us, err := strconv.ParseInt(v, 10, 64); err == nil && us >= 0 {
|
|
||||||
seconds = float64(us) / 1e6
|
|
||||||
}
|
|
||||||
case "speed":
|
|
||||||
if x, err := strconv.ParseFloat(strings.TrimSuffix(v, "x"), 64); err == nil {
|
|
||||||
speed = x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return seconds, speed
|
|
||||||
}
|
|
||||||
|
|
||||||
func newID() string {
|
|
||||||
b := make([]byte, 8)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The real thing, when ffmpeg is here: a generated clip goes through as H.264 and comes out
|
|
||||||
// as a playlist of segments, the status follows it to the end, and closing removes it all.
|
|
||||||
func TestStreamWithFFmpeg(t *testing.T) {
|
|
||||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
||||||
t.Skip("no ffmpeg")
|
|
||||||
}
|
|
||||||
c := Probe(context.Background())
|
|
||||||
if c.Error != "" {
|
|
||||||
t.Skip(c.Error)
|
|
||||||
}
|
|
||||||
if EngineFor(c, "h264") == "" {
|
|
||||||
t.Skip("nothing here encodes h264")
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
clip := filepath.Join(dir, "clip.mp4")
|
|
||||||
out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc=size=640x360:rate=25:duration=9", "-f", "lavfi", "-i", "sine=frequency=440:duration=9",
|
|
||||||
"-c:v", "mpeg4", "-q:v", "5", "-c:a", "aac", "-shortest", clip).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("could not make a clip: %s", out)
|
|
||||||
}
|
|
||||||
src, err := Inspect(context.Background(), c, clip, false)
|
|
||||||
if err != nil || src.Video == nil || src.Audio == nil || src.Video.Codec != "mpeg4" || src.Duration < 8 {
|
|
||||||
t.Fatalf("inspect: %+v %v", src, err)
|
|
||||||
}
|
|
||||||
m := NewStreamer(dir, "hamlab", 2)
|
|
||||||
s, err := m.Open(context.Background(), c, false, clip, "h264", "480p", src)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if s.Width != 640 || s.Height != 360 || s.Engine == "" || !s.Seekable {
|
|
||||||
t.Fatalf("stream: %+v", s)
|
|
||||||
}
|
|
||||||
if _, ok := s.File("index.m3u8"); !ok {
|
|
||||||
t.Fatal("no playlist")
|
|
||||||
}
|
|
||||||
if pl := string(s.Playlist()); !strings.Contains(pl, "#EXT-X-PLAYLIST-TYPE:VOD") || !strings.HasSuffix(pl, "#EXT-X-ENDLIST\n") || strings.Count(pl, "#EXTINF") != 3 {
|
|
||||||
t.Fatalf("playlist for 9 s:\n%s", pl)
|
|
||||||
}
|
|
||||||
if !exists(s.segPath(0)) {
|
|
||||||
t.Fatal("Open returned before the first segment")
|
|
||||||
}
|
|
||||||
for _, bad := range []string{"../x", "progress", "seg1.m4s", "index.m3u8/", "init.mp4.tmp"} {
|
|
||||||
if _, ok := s.File(bad); ok {
|
|
||||||
t.Fatalf("%q must not be served", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// the same request is the same stream
|
|
||||||
if again, err := m.Open(context.Background(), c, false, clip, "h264", "480p", src); err != nil || again.ID != s.ID {
|
|
||||||
t.Fatal("not reused")
|
|
||||||
}
|
|
||||||
var st StreamStatus
|
|
||||||
for i := 0; i < 120; i++ {
|
|
||||||
st = s.Status()
|
|
||||||
if st.Done || st.Error != "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if !st.Done || st.Error != "" || st.Segments != 3 || st.Progress < 8 {
|
|
||||||
t.Fatalf("status at the end: %+v", st)
|
|
||||||
}
|
|
||||||
if init, _ := s.File("init.mp4"); !exists(init) {
|
|
||||||
t.Fatal("no init segment")
|
|
||||||
}
|
|
||||||
m.Close(s.ID)
|
|
||||||
if _, ok := m.Find(s.ID); ok || exists(s.dir) {
|
|
||||||
t.Fatal("close must forget and remove")
|
|
||||||
}
|
|
||||||
// an unplayable source is a DecodeError, and leaves nothing behind
|
|
||||||
bad := filepath.Join(dir, "bad.mkv")
|
|
||||||
_ = os.WriteFile(bad, []byte("not a video"), 0o644)
|
|
||||||
if _, err := Inspect(context.Background(), c, bad, false); err == nil {
|
|
||||||
t.Fatal("inspect of garbage")
|
|
||||||
}
|
|
||||||
if left, _ := os.ReadDir(m.dir); len(left) != 0 {
|
|
||||||
t.Fatalf("left behind: %v", left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A seek ahead of the encode: the run moves there, the segment comes from a run that started
|
|
||||||
// at it, the gaps are filled afterwards, and the segments of the different runs, joined, are
|
|
||||||
// one film with no hole and nothing twice beyond a frame at a join.
|
|
||||||
func TestSeekRestartsTheEncode(t *testing.T) {
|
|
||||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
||||||
t.Skip("no ffmpeg")
|
|
||||||
}
|
|
||||||
c := Probe(context.Background())
|
|
||||||
if c.Error != "" || EngineFor(c, "h264") == "" {
|
|
||||||
t.Skip("nothing here encodes h264")
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
clip := filepath.Join(dir, "clip.mkv")
|
|
||||||
// big and busy enough that the first run is still near the start when the seek comes
|
|
||||||
out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=30000/1001:duration=64", "-f", "lavfi", "-i", "sine=frequency=440:duration=64",
|
|
||||||
"-c:v", "libx264", "-preset", "ultrafast", "-g", "250", "-c:a", "libopus", "-shortest", clip).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("could not make a clip: %s", out)
|
|
||||||
}
|
|
||||||
src, err := Inspect(context.Background(), c, clip, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m := NewStreamer(dir, "hamlab", 2)
|
|
||||||
s, err := m.Open(context.Background(), c, false, clip, "h264", "1080p", src)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer m.Close(s.ID)
|
|
||||||
if s.n != 16 {
|
|
||||||
t.Fatalf("%d segments for 64 s", s.n)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
p, err := s.Segment(ctx, 12)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
from := s.run.from
|
|
||||||
s.mu.Unlock()
|
|
||||||
if from != 12 {
|
|
||||||
t.Fatalf("the run after the seek starts at segment %d, not 12 (the first run was already there?)", from)
|
|
||||||
}
|
|
||||||
if got := firstVideoPTS(t, filepath.Join(s.dir, "init.mp4"), p); math.Abs(got-(48+tsBase)) > 0.05 {
|
|
||||||
t.Fatalf("segment 12 starts at %.3f, want %d", got, 48+tsBase)
|
|
||||||
}
|
|
||||||
if _, err := s.Segment(ctx, 16); !errors.Is(err, ErrNotInFilm) {
|
|
||||||
t.Fatalf("past the end: %v", err)
|
|
||||||
}
|
|
||||||
var st StreamStatus
|
|
||||||
for i := 0; i < 480; i++ {
|
|
||||||
if st = s.Status(); st.Done || st.Error != "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if !st.Done || st.Segments != 16 {
|
|
||||||
t.Fatalf("not filled in: %+v", st)
|
|
||||||
}
|
|
||||||
// the joined film: every segment in order behind the one init segment
|
|
||||||
joined := filepath.Join(dir, "joined.mp4")
|
|
||||||
f, _ := os.Create(joined)
|
|
||||||
for _, n := range append([]string{"init.mp4"}, segNames(16)...) {
|
|
||||||
b, err := os.ReadFile(filepath.Join(s.dir, n))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, _ = f.Write(b)
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
if out, err := exec.Command("ffmpeg", "-v", "error", "-i", joined, "-f", "null", "-").CombinedOutput(); err != nil || strings.Contains(string(out), "error") {
|
|
||||||
t.Fatalf("the joined film doesn't decode cleanly: %v %s", err, out)
|
|
||||||
}
|
|
||||||
pts := videoPTS(t, joined)
|
|
||||||
if len(pts) < 1900 || pts[0] > tsBase+0.1 || pts[len(pts)-1] < tsBase+63.5 {
|
|
||||||
t.Fatalf("%d frames, %.3f .. %.3f", len(pts), pts[0], pts[len(pts)-1])
|
|
||||||
}
|
|
||||||
for i := 1; i < len(pts); i++ {
|
|
||||||
if d := pts[i] - pts[i-1]; d > 0.07 { // a frame is 0.033; a join may lose up to one
|
|
||||||
t.Fatalf("a hole of %.3f s at %.3f", d, pts[i-1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func segNames(n int) []string {
|
|
||||||
var out []string
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, "seg"+strconv.FormatInt(int64(100000+i), 10)[1:]+".m4s")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func videoPTS(t *testing.T, file string) []float64 {
|
|
||||||
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v", "-show_entries", "packet=pts_time", "-of", "csv=p=0", file).Output()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var pts []float64
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, l := range strings.Fields(string(out)) {
|
|
||||||
if seen[l] { // the same frame from both runs at a join
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[l] = true
|
|
||||||
if v, err := strconv.ParseFloat(l, 64); err == nil {
|
|
||||||
pts = append(pts, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Float64s(pts)
|
|
||||||
return pts
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstVideoPTS(t *testing.T, init, seg string) float64 {
|
|
||||||
dir := t.TempDir()
|
|
||||||
j := filepath.Join(dir, "one.mp4")
|
|
||||||
a, _ := os.ReadFile(init)
|
|
||||||
b, _ := os.ReadFile(seg)
|
|
||||||
_ = os.WriteFile(j, append(a, b...), 0o644)
|
|
||||||
pts := videoPTS(t, j)
|
|
||||||
if len(pts) == 0 {
|
|
||||||
t.Fatal("no frames in the segment")
|
|
||||||
}
|
|
||||||
return pts[0]
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,7 @@ type Track struct {
|
|||||||
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
|
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
|
||||||
Channels int `json:"channels,omitempty"`
|
Channels int `json:"channels,omitempty"`
|
||||||
AttachedPic bool `json:"attached_pic,omitempty"`
|
AttachedPic bool `json:"attached_pic,omitempty"`
|
||||||
|
Spherical bool `json:"spherical,omitempty"` // an equirectangular 360 picture
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info is what a file holds: the picture to take, the sound to go with it, and the whole.
|
// Info is what a file holds: the picture to take, the sound to go with it, and the whole.
|
||||||
@@ -58,6 +59,10 @@ type probeJSON struct {
|
|||||||
Disposition struct {
|
Disposition struct {
|
||||||
AttachedPic int `json:"attached_pic"`
|
AttachedPic int `json:"attached_pic"`
|
||||||
} `json:"disposition"`
|
} `json:"disposition"`
|
||||||
|
SideData []struct {
|
||||||
|
Type string `json:"side_data_type"`
|
||||||
|
Projection string `json:"projection"`
|
||||||
|
} `json:"side_data_list"`
|
||||||
} `json:"streams"`
|
} `json:"streams"`
|
||||||
Format struct {
|
Format struct {
|
||||||
FormatName string `json:"format_name"`
|
FormatName string `json:"format_name"`
|
||||||
@@ -74,7 +79,7 @@ func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error)
|
|||||||
return Info{}, errors.New("ffprobe is not installed")
|
return Info{}, errors.New("ffprobe is not installed")
|
||||||
}
|
}
|
||||||
argv := []string{c.FFprobe, "-v", "error", "-show_entries",
|
argv := []string{c.FFprobe, "-v", "error", "-show_entries",
|
||||||
"stream=index,codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,avg_frame_rate,color_transfer,channels,disposition:format=format_name,duration,bit_rate,size",
|
"stream=index,codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,avg_frame_rate,color_transfer,channels,disposition:stream_side_data=side_data_type,projection:format=format_name,duration,bit_rate,size",
|
||||||
"-of", "json", path}
|
"-of", "json", path}
|
||||||
if root {
|
if root {
|
||||||
argv = priv.Wrap(argv...)
|
argv = priv.Wrap(argv...)
|
||||||
@@ -105,6 +110,15 @@ func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error)
|
|||||||
for _, s := range pj.Streams {
|
for _, s := range pj.Streams {
|
||||||
st := &Track{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height,
|
st := &Track{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height,
|
||||||
ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0}
|
ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0}
|
||||||
|
// A 360 video says so in its spherical tag (ffmpeg reads the old XML one and the sv3d
|
||||||
|
// box); an untagged one is still taken for 360 when it is 2:1 and too big for anything
|
||||||
|
// else.
|
||||||
|
for _, sd := range s.SideData {
|
||||||
|
if sd.Type == "Spherical Mapping" && strings.HasPrefix(sd.Projection, "equirectangular") {
|
||||||
|
st.Spherical = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Spherical = st.Spherical || (s.CodecType == "video" && s.Width == 2*s.Height && s.Width >= 3840)
|
||||||
st.FPS = fps(s.AvgFrameRate)
|
st.FPS = fps(s.AvgFrameRate)
|
||||||
if st.FPS == 0 {
|
if st.FPS == 0 {
|
||||||
st.FPS = fps(s.RFrameRate)
|
st.FPS = fps(s.RFrameRate)
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
// Playing a video in the browser at the quality the connection can take: HLS, made on the fly
|
||||||
|
// by ffmpeg on the device asked for (the Transcode device setting), YouTube's way. The player
|
||||||
|
// is told every quality is all there from the start, in Seg-second pieces, and each piece is
|
||||||
|
// made when it is first asked for.
|
||||||
|
//
|
||||||
|
// One ffmpeg runs per play at a time. It starts at the piece asked for and runs on ahead of the
|
||||||
|
// player, paused once it is far enough ahead; a piece asked for well past where it has got to
|
||||||
|
// (a seek), or at another quality, ends it and starts a new one there. Every run is made the
|
||||||
|
// same way (pieceArgs), so pieces from different runs and qualities follow on from one another.
|
||||||
|
//
|
||||||
|
// The file is a path on this machine or, when the device asked for is on another one, a URL
|
||||||
|
// the hub serves it at. Nothing is kept: a play nobody asks anything of is forgotten, and the
|
||||||
|
// folder is emptied when the daemon starts.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
runAhead = 10 // pieces a run may make past the last one asked for before it is paused
|
||||||
|
jump = 3 // a piece more than this far past what's made is a seek: start again there
|
||||||
|
idleRun = 45 * time.Second // a run nobody has asked anything of for this long is ended
|
||||||
|
idlePlay = 30 * time.Minute // a play nobody has asked anything of for this long is forgotten
|
||||||
|
budget = 2 << 30 // bytes of pieces one play keeps; those asked for least lately go first
|
||||||
|
// PieceWait is the longest a request waits for its piece: under the hub proxy's 60 s wait
|
||||||
|
// for a node to answer.
|
||||||
|
PieceWait = 45 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("no such piece")
|
||||||
|
|
||||||
|
// Plays is every play on this machine.
|
||||||
|
type Plays struct {
|
||||||
|
dir string
|
||||||
|
self string // this binary, for root runs through run-ffmpeg
|
||||||
|
maxRuns int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
plays map[string]*Play
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlays keeps pieces under <dataDir>/transcode, which it empties first.
|
||||||
|
func NewPlays(dataDir, self string, maxRuns int) *Plays {
|
||||||
|
dir := filepath.Join(dataDir, "transcode")
|
||||||
|
_ = os.MkdirAll(dir, 0o700)
|
||||||
|
_ = clearDir(dir)
|
||||||
|
m := &Plays{dir: dir, self: self, maxRuns: max(maxRuns, 1), plays: map[string]*Play{}, stop: make(chan struct{})}
|
||||||
|
go m.tend()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play is one video opened for watching.
|
||||||
|
type Play struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"` // the file's name, for the record
|
||||||
|
Info Info `json:"info"`
|
||||||
|
Rungs []Rung `json:"rungs"` // best first
|
||||||
|
Device string `json:"device"` // asked for: a render node, "cpu", or "" for the best here
|
||||||
|
Remote bool `json:"remote"` // the file is on another machine, read over the network
|
||||||
|
|
||||||
|
caps Caps
|
||||||
|
in string
|
||||||
|
root bool
|
||||||
|
segs int
|
||||||
|
dir string
|
||||||
|
m *Plays
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
seen time.Time
|
||||||
|
run *playRun
|
||||||
|
tried map[int]int // per rung: recipes that failed to make anything, skipped from now on
|
||||||
|
}
|
||||||
|
|
||||||
|
type playRun struct {
|
||||||
|
rung int
|
||||||
|
recipe Recipe
|
||||||
|
start int // the first piece this run makes
|
||||||
|
head int // the last piece finished (start-1 before the first)
|
||||||
|
want int // the last piece asked for
|
||||||
|
asked time.Time
|
||||||
|
proc *ffproc
|
||||||
|
paused bool
|
||||||
|
dead bool // ended, or exited
|
||||||
|
killed bool // ended by us, not by itself
|
||||||
|
exited bool
|
||||||
|
err error
|
||||||
|
tick chan struct{} // closed when head moves or the run ends
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPlayID() string {
|
||||||
|
b := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open starts a play of in (a path, or an http URL for a file on another machine). Nothing
|
||||||
|
// runs until a piece is asked for.
|
||||||
|
func (m *Plays) Open(c Caps, root bool, in, name string, src Info, device string) (*Play, error) {
|
||||||
|
rungs := Ladder(src)
|
||||||
|
if len(rungs) == 0 || src.Duration <= 0 {
|
||||||
|
return nil, errors.New("this video's length or picture size isn't known, so it can't be played")
|
||||||
|
}
|
||||||
|
if len(recipes(c, device, src, rungs[0])) == 0 {
|
||||||
|
return nil, errors.New("nothing on this machine encodes H.264 (no GPU that does, and no libx264)")
|
||||||
|
}
|
||||||
|
segs := max(int(math.Ceil((src.Duration-0.25)/Seg)), 1) // the last piece takes in what's left
|
||||||
|
p := &Play{ID: newPlayID(), Name: name, Info: src, Rungs: rungs, Device: device, Remote: strings.HasPrefix(in, "http"),
|
||||||
|
caps: c, in: in, root: root, segs: segs, m: m, seen: time.Now(), tried: map[int]int{}}
|
||||||
|
p.dir = filepath.Join(m.dir, p.ID)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.plays[p.ID] = p
|
||||||
|
m.mu.Unlock()
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Plays) Find(id string) (*Play, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
p, ok := m.plays[id]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ends a play and deletes its pieces.
|
||||||
|
func (m *Plays) Close(id string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
p, ok := m.plays[id]
|
||||||
|
delete(m.plays, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.end()
|
||||||
|
p.mu.Unlock()
|
||||||
|
go os.RemoveAll(p.dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Play) rung(name string) int {
|
||||||
|
for i, r := range p.Rungs {
|
||||||
|
if r.Name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master is the playlist of qualities, lowest first, so a player that starts on the first one
|
||||||
|
// listed starts on the one that surely plays.
|
||||||
|
func (p *Play) Master() []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||||
|
for i := len(p.Rungs) - 1; i >= 0; i-- {
|
||||||
|
r := p.Rungs[i]
|
||||||
|
fmt.Fprintf(&b, "#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,RESOLUTION=%dx%d,FRAME-RATE=%.3f,CODECS=\"%s\"\n%s.m3u8\n",
|
||||||
|
r.Peak, r.Bitrate+audioRate, r.Width, r.Height, r.FPS, r.codecs, r.Name)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media is one quality's playlist: every piece, made or not.
|
||||||
|
func (p *Play) Media(name string) ([]byte, bool) {
|
||||||
|
if p.rung(name) < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
last := p.Info.Duration - float64(p.segs-1)*Seg
|
||||||
|
fmt.Fprintf(&b, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n",
|
||||||
|
int(math.Ceil(math.Max(last, Seg))))
|
||||||
|
for n := 0; n < p.segs; n++ {
|
||||||
|
d := Seg
|
||||||
|
if n == p.segs-1 {
|
||||||
|
d = last
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "#EXTINF:%.6f,\n%s/%d.ts\n", d, name, n)
|
||||||
|
}
|
||||||
|
b.WriteString("#EXT-X-ENDLIST\n")
|
||||||
|
return b.Bytes(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Play) piece(rung, n int) string {
|
||||||
|
return filepath.Join(p.dir, p.Rungs[rung].Name, strconv.Itoa(n)+".ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment answers the file of piece n at quality name, making it first if need be: it waits
|
||||||
|
// for the run already making it, or starts one there.
|
||||||
|
func (p *Play) Segment(done <-chan struct{}, name string, n int) (string, error) {
|
||||||
|
ri := p.rung(name)
|
||||||
|
if ri < 0 || n < 0 || n >= p.segs {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
path := p.piece(ri, n)
|
||||||
|
deadline := time.After(PieceWait)
|
||||||
|
for {
|
||||||
|
p.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
p.seen = now
|
||||||
|
r := p.run
|
||||||
|
if r != nil && r.rung == ri && !r.dead {
|
||||||
|
r.want, r.asked = n, now
|
||||||
|
}
|
||||||
|
if exists(path) {
|
||||||
|
p.mu.Unlock()
|
||||||
|
_ = os.Chtimes(path, now, now) // the budget keeps what was asked for last
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if r != nil && r.exited && !r.killed && r.rung == ri && r.start == n {
|
||||||
|
// This run started at this very piece and ended without it: that way of making
|
||||||
|
// it doesn't work here. The next one is tried, if there is one.
|
||||||
|
p.tried[ri]++
|
||||||
|
log.Printf("files: play %s: %s made nothing at %s piece %d: %v: %s", p.Name, r.recipe.Engine, name, n, r.err, r.proc.lastWords())
|
||||||
|
if p.tried[ri] >= len(recipes(p.caps, p.Device, p.Info, p.Rungs[ri])) {
|
||||||
|
err := fmt.Errorf("couldn't make %s: %s", p.Rungs[ri].Label, r.proc.lastWords())
|
||||||
|
p.mu.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
p.run = nil
|
||||||
|
r = nil
|
||||||
|
}
|
||||||
|
if r == nil || r.dead || r.rung != ri || n < r.start || n <= r.head || n > r.head+jump {
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.m.makeRoom(p)
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.run == r { // nobody started one meanwhile
|
||||||
|
if err := p.start(ri, n); err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tick := r.tick
|
||||||
|
p.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-tick:
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
case <-done:
|
||||||
|
return "", errors.New("gone")
|
||||||
|
case <-deadline:
|
||||||
|
return "", fmt.Errorf("%s piece %d took longer than %s", name, n, PieceWait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// end stops this play's run. p.mu is held.
|
||||||
|
func (p *Play) end() {
|
||||||
|
if r := p.run; r != nil && !r.dead {
|
||||||
|
r.dead, r.killed = true, true
|
||||||
|
r.proc.kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRoom ends the run asked of least lately, if every slot is taken by another play. It
|
||||||
|
// never holds two plays' locks at once.
|
||||||
|
func (m *Plays) makeRoom(except *Play) {
|
||||||
|
m.mu.Lock()
|
||||||
|
plays := make([]*Play, 0, len(m.plays))
|
||||||
|
for _, p := range m.plays {
|
||||||
|
if p != except {
|
||||||
|
plays = append(plays, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
var oldest *Play
|
||||||
|
var when time.Time
|
||||||
|
running := 0
|
||||||
|
for _, p := range plays {
|
||||||
|
p.mu.Lock()
|
||||||
|
if r := p.run; r != nil && !r.dead {
|
||||||
|
running++
|
||||||
|
if oldest == nil || r.asked.Before(when) {
|
||||||
|
oldest, when = p, r.asked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
if running >= m.maxRuns && oldest != nil {
|
||||||
|
oldest.mu.Lock()
|
||||||
|
oldest.end()
|
||||||
|
oldest.mu.Unlock()
|
||||||
|
log.Printf("files: %d plays encoding; ended %s's, asked of least lately", running, oldest.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start begins a run making quality ri from piece n on, ending the one before. p.mu is held.
|
||||||
|
func (p *Play) start(ri, n int) error {
|
||||||
|
p.end()
|
||||||
|
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[ri])
|
||||||
|
if p.tried[ri] >= len(rs) {
|
||||||
|
return errors.New("nothing here can make this quality")
|
||||||
|
}
|
||||||
|
rc := rs[p.tried[ri]]
|
||||||
|
out := filepath.Join(p.dir, p.Rungs[ri].Name)
|
||||||
|
if err := os.MkdirAll(out, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
proc, err := startFF(p.caps, p.m.self, p.root, pieceArgs(out, p.in, p.Info, rc, p.Rungs[ri], n))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := &playRun{rung: ri, recipe: rc, start: n, head: n - 1, want: n, asked: time.Now(), proc: proc, tick: make(chan struct{})}
|
||||||
|
p.run = r
|
||||||
|
go func() {
|
||||||
|
<-proc.done
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
r.dead, r.exited, r.err = true, true, proc.err
|
||||||
|
p.scan(r)
|
||||||
|
close(r.tick)
|
||||||
|
r.tick = make(chan struct{})
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scan moves a run's head on past the pieces it has finished. p.mu is held.
|
||||||
|
func (p *Play) scan(r *playRun) bool {
|
||||||
|
moved := false
|
||||||
|
for r.head+1 < p.segs && exists(p.piece(r.rung, r.head+1)) {
|
||||||
|
r.head++
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
return moved
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayStatus is what a play is doing, for the player's menu.
|
||||||
|
type PlayStatus struct {
|
||||||
|
Engine string `json:"engine,omitempty"` // what the run going now runs on
|
||||||
|
Quality string `json:"quality,omitempty"`
|
||||||
|
From int `json:"from"` // seconds into the film the run started at
|
||||||
|
Ahead int `json:"ahead"`
|
||||||
|
Paused bool `json:"paused,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Play) Status() PlayStatus {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
r := p.run
|
||||||
|
if r == nil || r.dead {
|
||||||
|
return PlayStatus{}
|
||||||
|
}
|
||||||
|
return PlayStatus{Engine: r.recipe.Engine, Quality: p.Rungs[r.rung].Label, From: int(float64(r.start) * Seg), Ahead: r.head - r.want, Paused: r.paused}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine is what this play would run on first, for the player to say before anything runs.
|
||||||
|
func (p *Play) Engine() string {
|
||||||
|
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[0])
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rs[0].Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
// tend, ten times a second: notices finished pieces, pauses a run that is far enough ahead and
|
||||||
|
// wakes it when the player catches up, ends one nobody is asking anything of; and every ten
|
||||||
|
// seconds keeps each play inside its budget and forgets plays nobody watches.
|
||||||
|
func (m *Plays) tend() {
|
||||||
|
t := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
select {
|
||||||
|
case <-m.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
plays := make([]*Play, 0, len(m.plays))
|
||||||
|
for _, p := range m.plays {
|
||||||
|
plays = append(plays, p)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
for _, p := range plays {
|
||||||
|
p.mu.Lock()
|
||||||
|
if r := p.run; r != nil && !r.dead {
|
||||||
|
if p.scan(r) {
|
||||||
|
close(r.tick)
|
||||||
|
r.tick = make(chan struct{})
|
||||||
|
}
|
||||||
|
ahead := r.head - r.want
|
||||||
|
switch {
|
||||||
|
case now.Sub(r.asked) > idleRun:
|
||||||
|
p.end()
|
||||||
|
case !r.paused && ahead >= runAhead:
|
||||||
|
if r.proc.pause() == nil {
|
||||||
|
r.paused = true
|
||||||
|
}
|
||||||
|
case r.paused && ahead < runAhead-3:
|
||||||
|
if r.proc.resume() == nil {
|
||||||
|
r.paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idle := now.Sub(p.seen) > idlePlay
|
||||||
|
p.mu.Unlock()
|
||||||
|
if idle {
|
||||||
|
m.Close(p.ID)
|
||||||
|
} else if i%100 == 0 {
|
||||||
|
p.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim deletes the pieces asked for least lately until the play is inside its budget, never
|
||||||
|
// the ones its run has made ahead of the player.
|
||||||
|
func (p *Play) trim() {
|
||||||
|
type piece struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
var all []piece
|
||||||
|
var total int64
|
||||||
|
_ = filepath.WalkDir(p.dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".ts") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if fi, err := d.Info(); err == nil {
|
||||||
|
all = append(all, piece{path, fi.Size(), fi.ModTime()})
|
||||||
|
total += fi.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if total <= budget {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
keep := map[string]bool{}
|
||||||
|
if r := p.run; r != nil && !r.dead {
|
||||||
|
for n := r.want - 1; n <= r.head+1; n++ {
|
||||||
|
if n >= 0 && n < p.segs {
|
||||||
|
keep[p.piece(r.rung, n)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
sort.Slice(all, func(a, b int) bool { return all[a].at.Before(all[b].at) })
|
||||||
|
for _, x := range all {
|
||||||
|
if total <= budget*3/4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !keep[x.path] && os.Remove(x.path) == nil {
|
||||||
|
total -= x.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inspectPiece answers when a piece's first video packet is shown, whether it is a keyframe,
|
||||||
|
// how many keyframes the piece has, and when its last packet is.
|
||||||
|
func inspectPiece(t *testing.T, path string) (first float64, key bool, keys int, last float64) {
|
||||||
|
t.Helper()
|
||||||
|
b, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "packet=pts_time,flags,codec_type", "-of", "json", path).Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffprobe %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Packets []struct {
|
||||||
|
PTS string `json:"pts_time"`
|
||||||
|
Flags string `json:"flags"`
|
||||||
|
Type string `json:"codec_type"`
|
||||||
|
} `json:"packets"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(b, &p)
|
||||||
|
first = -1
|
||||||
|
for _, k := range p.Packets {
|
||||||
|
if k.Type != "video" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, _ := strconv.ParseFloat(k.PTS, 64)
|
||||||
|
if first < 0 {
|
||||||
|
first, key = x, strings.HasPrefix(k.Flags, "K")
|
||||||
|
}
|
||||||
|
first = math.Min(first, x)
|
||||||
|
last = math.Max(last, x)
|
||||||
|
if strings.HasPrefix(k.Flags, "K") {
|
||||||
|
keys++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPieces makes pieces of a made-up video (or SRC, on DEVICE, to try the real thing on a
|
||||||
|
// machine with a GPU) in order and after a seek, at two qualities, and checks they line up:
|
||||||
|
// each starts on a keyframe at its own place in the film, with no other keyframe inside it.
|
||||||
|
func TestPieces(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("no ffmpeg")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
in := os.Getenv("SRC")
|
||||||
|
if in == "" {
|
||||||
|
in = filepath.Join(dir, "in.mkv")
|
||||||
|
if out, err := exec.Command("ffmpeg", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=50:duration=40",
|
||||||
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=40", "-c:v", "libx264", "-preset", "ultrafast", "-g", "500",
|
||||||
|
"-c:a", "libopus", "-shortest", in).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("making the test video: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caps := Probe(context.Background())
|
||||||
|
if caps.Error != "" {
|
||||||
|
t.Skip(caps.Error)
|
||||||
|
}
|
||||||
|
info, err := Inspect(context.Background(), caps, in, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := NewPlays(dir, "", 2)
|
||||||
|
device := os.Getenv("DEVICE")
|
||||||
|
if device == "" {
|
||||||
|
device = CPU
|
||||||
|
}
|
||||||
|
p, err := m.Open(caps, false, in, "in.mkv", info, device)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close(p.ID)
|
||||||
|
if !strings.Contains(string(p.Master()), "RESOLUTION=") {
|
||||||
|
t.Fatal(string(p.Master()))
|
||||||
|
}
|
||||||
|
low := p.Rungs[len(p.Rungs)-1].Name
|
||||||
|
var offset float64
|
||||||
|
far := min(p.segs-2, 60)
|
||||||
|
for i, n := range []int{0, 1, 2, far, far + 1, 3} {
|
||||||
|
t0 := time.Now()
|
||||||
|
path, err := p.Segment(nil, low, n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("piece %d: %v", n, err)
|
||||||
|
}
|
||||||
|
first, key, keys, last := inspectPiece(t, path)
|
||||||
|
if i == 0 {
|
||||||
|
offset = first
|
||||||
|
}
|
||||||
|
at := first - offset
|
||||||
|
t.Logf("piece %d: %d ms on %s, starts %.3f, %d keyframe(s), %.2f s long", n, time.Since(t0).Milliseconds(), p.Status().Engine, at, keys, last-first)
|
||||||
|
if !key || keys != 1 {
|
||||||
|
t.Errorf("piece %d: first packet keyframe %v, %d keyframes", n, key, keys)
|
||||||
|
}
|
||||||
|
if math.Abs(at-float64(n)*Seg) > 0.05 {
|
||||||
|
t.Errorf("piece %d starts at %.3f, want %.3f", n, at, float64(n)*Seg)
|
||||||
|
}
|
||||||
|
if n < p.segs-1 && math.Abs(last-first-Seg) > 0.1 {
|
||||||
|
t.Errorf("piece %d is %.3f s long", n, last-first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// another quality, at a piece already made at the first: its own run, lined up too
|
||||||
|
if len(p.Rungs) > 1 {
|
||||||
|
path, err := p.Segment(nil, p.Rungs[0].Name, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first, key, _, _ := inspectPiece(t, path); !key || math.Abs(first-offset-2*Seg) > 0.05 {
|
||||||
|
t.Errorf("%s piece 2 starts %.3f (keyframe %v)", p.Rungs[0].Name, first-offset, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := p.Segment(nil, low, p.segs); err != ErrNotFound {
|
||||||
|
t.Errorf("past the end: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.Segment(nil, "999", 0); err != ErrNotFound {
|
||||||
|
t.Errorf("no such quality: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -404,6 +404,12 @@ func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte
|
|||||||
return resp.StatusCode, b, err
|
return resp.StatusCode, b, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL is a managed machine's address, "" for one not managed here.
|
||||||
|
func (m *Manager) URL(name string) string {
|
||||||
|
n, _ := m.node(name)
|
||||||
|
return n.URL
|
||||||
|
}
|
||||||
|
|
||||||
// Names lists the managed machines.
|
// Names lists the managed machines.
|
||||||
func (m *Manager) Names() []string {
|
func (m *Manager) Names() []string {
|
||||||
var out []string
|
var out []string
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ type State struct {
|
|||||||
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||||
|
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
||||||
|
// a machine and a device on it. nil is the machine the file is on, its best device.
|
||||||
|
Transcode *Transcode `json:"transcode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode names a device on a machine: Node "" is the hub itself; Device is a render node
|
||||||
|
// or "cpu".
|
||||||
|
type Transcode struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Device string `json:"device"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserState returns the prefs of an account, making an empty one if needed.
|
// UserState returns the prefs of an account, making an empty one if needed.
|
||||||
|
|||||||
+1
-1
@@ -267,7 +267,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||||
)}
|
)}
|
||||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||||
{/* Video: the browser's own decoder first, and a re-encode on the machine when that fails. Audio as it is. */}
|
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
||||||
|
|||||||
+329
-125
@@ -1,153 +1,357 @@
|
|||||||
// A video in the Files viewer. First the file as it is, handed to the browser's own
|
// A video in the Files viewer, YouTube's way: Auto, which reads the connection and picks the
|
||||||
// decoder; when that fails — Safari and an MKV, HEVC without a hardware decoder — a
|
// quality (hls.js), any quality by hand, or Original — the file as it is, for a browser that
|
||||||
// picker: codec and size, and the machine re-encodes it to HLS on its own GPU (or CPU),
|
// decodes it. The qualities are made on the fly, from wherever the viewer seeks to, on the device
|
||||||
// which plays natively on Safari and through hls.js everywhere else. The same picker is
|
// the Transcode device setting names (Settings), which may be on another machine than the file.
|
||||||
// one tap away even when the file plays, for a 36 Mbps capture over a slow link.
|
// A 360° video is drawn as the view from inside it (vr.ts): drag, pinch or scroll, and on a
|
||||||
// A re-encode's timeline is the whole film from the start: seek anywhere, and the machine
|
// phone the motion button. The same player as Ham Share's.
|
||||||
// starts encoding from there (repackaging, which is near-instant, grows as it goes instead).
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, apiPath, PlayCaps, PlayStatus, PlayStream } from './api'
|
import Hls from 'hls.js'
|
||||||
import { Btn, Spinner } from './ui'
|
import { api, nodeAPI, type PlayOpen, type PlayStatus } from './api'
|
||||||
|
import { canMotion, Sphere } from './vr'
|
||||||
|
|
||||||
type Mode = 'native' | 'pick' | 'hls'
|
type Choice = 'auto' | 'original' | number // a number is a width: the rung's name
|
||||||
const CODEC_KEY = 'hamlab.play.codec'
|
|
||||||
const QUALITY_KEY = 'hamlab.play.quality'
|
|
||||||
const remember = (k: string, v: string) => { try { localStorage.setItem(k, v) } catch { /* private mode */ } }
|
|
||||||
const recall = (k: string, d: string) => { try { return localStorage.getItem(k) || d } catch { return d } }
|
|
||||||
|
|
||||||
const clock = (sec: number) => { const t = Math.floor(sec), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` }
|
// The bandwidth hls.js measured last time, so the next video starts near the right quality
|
||||||
const fmtMbps = (bps?: number) => (bps ? `${(bps / 1e6).toFixed(bps >= 10e6 ? 0 : 1)} Mbps` : '')
|
// rather than at the bottom.
|
||||||
const describe = (s: PlayStream) => {
|
const BW_KEY = 'hamlab.play.bw'
|
||||||
const v = s.source.video
|
const savedBW = () => { try { const n = Number(localStorage.getItem(BW_KEY)); return n > 0 ? n : 0 } catch { return 0 } }
|
||||||
const src = v ? `${v.codec}${v.profile ? ' ' + v.profile : ''} ${v.width}×${v.height}${v.fps ? ` ${Math.round(v.fps)} fps` : ''}${s.source.bit_rate ? ` ${fmtMbps(s.source.bit_rate)}` : ''} in ${s.source.container.split(',')[0]}` : s.source.container
|
const saveBW = (n: number) => { try { if (n > 0) localStorage.setItem(BW_KEY, String(Math.round(n))) } catch { /* private mode */ } }
|
||||||
const out = s.codec === 'copy' ? 'repackaged' : `${s.codec === 'h264' ? 'H.264' : s.codec.toUpperCase()} ${s.width}×${s.height} ${(s.kbps / 1000).toFixed(s.kbps >= 10000 ? 0 : 1)} Mbps`
|
|
||||||
return `${src} → ${out} on ${s.engine}`
|
// hls.js reads the connection from how long each piece takes. Here a piece can be slow to
|
||||||
|
// start because the machine is still making it (a few seconds after a seek or a change of
|
||||||
|
// quality, at 8K), which says nothing about the connection; counted, it drags Auto down to the
|
||||||
|
// bottom, and every drop starts the making over again. So: the wait before the first byte is
|
||||||
|
// not counted against the connection, and hls.js doesn't give up on a piece before its first
|
||||||
|
// byte comes. Once bytes flow, its usual rules apply.
|
||||||
|
type AbrLike = { new(hls: Hls): object }
|
||||||
|
const Base = Hls.DefaultConfig.abrController as unknown as AbrLike
|
||||||
|
type Stats = { loading: { start: number; first: number } }
|
||||||
|
type Proto = { initEstimator: () => { sampleTTFB: (ms: number) => void }; onFragBuffered: (ev: unknown, d: unknown) => void }
|
||||||
|
const proto = Base.prototype as unknown as Proto
|
||||||
|
class PatientAbr extends Base {
|
||||||
|
constructor(hls: Hls) {
|
||||||
|
super(hls)
|
||||||
|
const self = this as unknown as { _abandonRulesCheck: (l?: unknown) => void; fragCurrent: { stats: Stats } | null }
|
||||||
|
const orig = self._abandonRulesCheck
|
||||||
|
self._abandonRulesCheck = (l?: unknown) => {
|
||||||
|
const f = self.fragCurrent
|
||||||
|
if (f && !f.stats.loading.first) return
|
||||||
|
orig(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initEstimator() {
|
||||||
|
const e = proto.initEstimator.call(this)
|
||||||
|
const sample = e.sampleTTFB.bind(e)
|
||||||
|
e.sampleTTFB = (ms: number) => sample(Math.min(ms, 300))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
onFragBuffered(ev: unknown, d: { frag: { stats: Stats }; part?: { stats: Stats & { loaded: number } } | null }) {
|
||||||
|
const st = d.part?.stats.loaded ? d.part.stats : d.frag.stats
|
||||||
|
const start = st.loading.start
|
||||||
|
if (st.loading.first > start) st.loading.start = st.loading.first
|
||||||
|
try { proto.onFragBuffered.call(this, ev, d) } finally { st.loading.start = start }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Player({ path, sudo, name }: { path: string; sudo: boolean; name: string }) {
|
const mbps = (b: number) => `${b >= 10e6 ? Math.round(b / 1e6) : (b / 1e6).toFixed(1)} Mb/s`
|
||||||
const [mode, setMode] = useState<Mode>('native')
|
const clock = (sec: number) => { const t = Math.max(0, Math.floor(sec)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` }
|
||||||
const [why, setWhy] = useState('') // what brought the picker up
|
const size = (w?: number, h?: number, vr?: boolean) => {
|
||||||
const [caps, setCaps] = useState<PlayCaps | null>(null)
|
if (!w || !h) return ''
|
||||||
const [codec, setCodec] = useState(() => recall(CODEC_KEY, 'h264'))
|
if (vr) return `${w >= 7680 ? '8K' : w >= 5760 ? '5.7K' : w >= 3840 ? '4K' : w >= 3072 ? '3K' : `${w}×${h}`} 360°`
|
||||||
const [quality, setQuality] = useState(() => recall(QUALITY_KEY, '1080p'))
|
const s = Math.min(w, h)
|
||||||
const [stream, setStream] = useState<{ s: PlayStream; url: string } | null>(null)
|
return s >= 2160 ? '4K' : s >= 1440 ? '1440p' : s >= 1080 ? '1080p' : s >= 720 ? '720p' : `${w}×${h}`
|
||||||
const [status, setStatus] = useState<PlayStatus | null>(null)
|
}
|
||||||
|
|
||||||
|
const I = ({ d, size: sz = 20, fill }: { d: string; size?: number; fill?: boolean }) => (
|
||||||
|
<svg width={sz} height={sz} viewBox="0 0 24 24" fill={fill ? 'currentColor' : 'none'} stroke={fill ? 'none' : 'currentColor'} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
|
||||||
|
)
|
||||||
|
const PLAY = 'M7 4.5v15l12.5-7.5z', PAUSE = 'M8 5v14M16 5v14', CHECK = 'M5 12.5l4.5 4.5L19 7.5', TUNE = 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4'
|
||||||
|
const FULL = 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', SMALL = 'M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5', PHONE = 'M8 3h8a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 9a9 9 0 0 0 0 6M21 9a9 9 0 0 1 0 6'
|
||||||
|
|
||||||
|
export default function Player({ path, sudo }: { path: string; sudo: boolean; name: string }) {
|
||||||
|
const [open, setOpen] = useState<PlayOpen | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [choice, setChoice] = useState<Choice>('auto')
|
||||||
|
const [showing, setShowing] = useState(0) // the width playing now
|
||||||
|
const [status, setStatus] = useState<PlayStatus | null>(null)
|
||||||
|
const [paused, setPaused] = useState(true)
|
||||||
|
const [waiting, setWaiting] = useState(true)
|
||||||
|
const [time, setTime] = useState(0)
|
||||||
|
const [scrub, setScrub] = useState<number | null>(null)
|
||||||
|
const [buffered, setBuffered] = useState(0)
|
||||||
|
const [menu, setMenu] = useState(false)
|
||||||
|
const [chrome, setChrome] = useState(true)
|
||||||
|
const [motion, setMotion] = useState(false)
|
||||||
|
const [full, setFull] = useState(false) // the browser's full screen, or (an iPhone) the window
|
||||||
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const video = useRef<HTMLVideoElement>(null)
|
const video = useRef<HTMLVideoElement>(null)
|
||||||
const streamRef = useRef(stream)
|
const canvas = useRef<HTMLCanvasElement>(null)
|
||||||
streamRef.current = stream
|
const hls = useRef<Hls | null>(null)
|
||||||
|
const sphere = useRef<Sphere | null>(null)
|
||||||
|
const hideTimer = useRef<number>()
|
||||||
|
const resume = useRef<{ at: number; play: boolean }>({ at: 0, play: true })
|
||||||
|
const openRef = useRef(open)
|
||||||
|
openRef.current = open
|
||||||
|
const menuRef = useRef(menu)
|
||||||
|
menuRef.current = menu
|
||||||
|
|
||||||
useEffect(() => { setMode('native'); setWhy(''); setStream(null); setStatus(null); setErr('') }, [path, sudo])
|
// Open the play; let it go when this player does, and when the page goes, by a beacon.
|
||||||
useEffect(() => { remember(CODEC_KEY, codec) }, [codec])
|
|
||||||
useEffect(() => { remember(QUALITY_KEY, quality) }, [quality])
|
|
||||||
useEffect(() => { if (mode === 'pick' && !caps) api.playCaps(sudo).then(setCaps).catch((e) => setCaps({ codecs: [], qualities: [], error: e instanceof Error ? e.message : 'failed' })) }, [mode, caps, sudo])
|
|
||||||
|
|
||||||
// a stream is the machine's work: let go of it when this player does — and when the page
|
|
||||||
// itself goes, by a beacon, the one request a browser still sends on its way out
|
|
||||||
const stop = useCallback(() => { const s = streamRef.current; if (s) { api.playStop(s.s.id).catch(() => { /* gone already */ }); setStream(null); setStatus(null) } }, [])
|
|
||||||
useEffect(() => () => stop(), [stop])
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = () => { const s = streamRef.current; if (s) navigator.sendBeacon(apiPath(`/api/files/play/${s.s.id}/close`)) }
|
let live = true
|
||||||
|
setOpen(null); setErr(''); setNote(''); setChoice('auto'); resume.current = { at: 0, play: true }
|
||||||
|
api.play(path, sudo).then((o) => {
|
||||||
|
if (!live) { api.playStop(o.node, o.play.id).catch(() => {}); return }
|
||||||
|
setOpen(o)
|
||||||
|
if (o.note) setNote(o.note)
|
||||||
|
}).catch((e) => { if (live) setErr(e instanceof Error ? e.message : 'failed') })
|
||||||
|
return () => { live = false; const o = openRef.current; if (o) api.playStop(o.node, o.play.id).catch(() => { /* gone already */ }) }
|
||||||
|
}, [path, sudo])
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => { const o = openRef.current; if (o) navigator.sendBeacon(nodeAPI(o.node, `/api/files/play/${o.play.id}/close`)) }
|
||||||
window.addEventListener('pagehide', h)
|
window.addEventListener('pagehide', h)
|
||||||
return () => window.removeEventListener('pagehide', h)
|
return () => window.removeEventListener('pagehide', h)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const start = async () => {
|
const info = open?.play.info
|
||||||
setErr('')
|
const vr = !!info?.video?.spherical
|
||||||
try {
|
|
||||||
stop()
|
|
||||||
const r = await api.playStart(path, codec, quality, sudo)
|
|
||||||
setStream({ s: r.stream, url: r.url }); setMode('hls')
|
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
||||||
}
|
|
||||||
|
|
||||||
// feed the stream to the element: Safari plays HLS itself, the rest through hls.js
|
useEffect(() => {
|
||||||
|
if (!vr || !canvas.current || !video.current) return
|
||||||
|
try { sphere.current = new Sphere(canvas.current, video.current) } catch (e) { setErr((e as Error).message) }
|
||||||
|
return () => { sphere.current?.destroy(); sphere.current = null }
|
||||||
|
}, [vr, open])
|
||||||
|
|
||||||
|
// The source: hls.js for Auto and the chosen qualities, the file itself for Original.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = video.current
|
const v = video.current
|
||||||
if (mode !== 'hls' || !stream || !v) return
|
if (!open || !v) return
|
||||||
const url = apiPath(stream.url)
|
const { at, play } = resume.current
|
||||||
let hls: { destroy: () => void } | null = null
|
const start = () => { if (at) v.currentTime = at; if (play) v.play().catch(() => setPaused(true)) }
|
||||||
let gone = false
|
if (choice === 'original') {
|
||||||
;(async () => {
|
v.src = api.fileReadURL(path, sudo)
|
||||||
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
v.addEventListener('loadedmetadata', start, { once: true })
|
||||||
v.src = url
|
setShowing(info?.video?.width ?? 0)
|
||||||
} else {
|
const bad = () => {
|
||||||
const { default: Hls } = await import('hls.js')
|
resume.current = { at: v.currentTime || at, play: true }
|
||||||
if (gone) return
|
setNote('This browser can’t play the file as it is. Back on Auto.')
|
||||||
if (!Hls.isSupported()) { setErr('this browser has neither HLS nor MediaSource — nothing can play here'); return }
|
setChoice('auto')
|
||||||
// a segment the machine hasn't made yet is made when asked for (a seek): its first
|
|
||||||
// byte can take as long as the re-encode takes to start there and write it
|
|
||||||
const slow = { maxTimeToFirstByteMs: 50000, maxLoadTimeMs: 120000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 } }
|
|
||||||
const h = new Hls({ maxBufferLength: 60, backBufferLength: 30, fragLoadPolicy: { default: slow } })
|
|
||||||
hls = h
|
|
||||||
h.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) setErr(`playback failed: ${d.details}`) })
|
|
||||||
h.loadSource(url)
|
|
||||||
h.attachMedia(v)
|
|
||||||
}
|
}
|
||||||
v.play().catch(() => { /* needs a tap on some phones */ })
|
v.addEventListener('error', bad, { once: true })
|
||||||
})()
|
return () => { v.removeEventListener('error', bad); v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
|
||||||
return () => { gone = true; hls?.destroy() }
|
}
|
||||||
}, [mode, stream])
|
const master = nodeAPI(open.node, open.master)
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const h = new Hls({
|
||||||
|
abrController: PatientAbr as unknown as typeof Hls.DefaultConfig.abrController,
|
||||||
|
abrEwmaDefaultEstimate: savedBW() || 5_000_000,
|
||||||
|
startPosition: at || -1,
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60,
|
||||||
|
backBufferLength: 60,
|
||||||
|
// A piece being made after a seek can take a while; the machine waits up to 45 s.
|
||||||
|
fragLoadPolicy: { default: { maxTimeToFirstByteMs: 50_000, maxLoadTimeMs: 120_000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 4, retryDelayMs: 1000, maxRetryDelayMs: 8000 } } },
|
||||||
|
})
|
||||||
|
hls.current = h
|
||||||
|
h.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
if (typeof choice === 'number') {
|
||||||
|
const i = h.levels.findIndex((l) => l.width === choice)
|
||||||
|
if (i >= 0) { h.startLevel = i; h.currentLevel = i }
|
||||||
|
}
|
||||||
|
if (play) v.play().catch(() => setPaused(true))
|
||||||
|
})
|
||||||
|
h.on(Hls.Events.LEVEL_SWITCHED, (_, d) => setShowing(h.levels[d.level]?.width ?? 0))
|
||||||
|
h.on(Hls.Events.FRAG_BUFFERED, () => saveBW(h.bandwidthEstimate))
|
||||||
|
let recovered = 0
|
||||||
|
h.on(Hls.Events.ERROR, (_, d) => {
|
||||||
|
if (!d.fatal) return
|
||||||
|
if (d.type === Hls.ErrorTypes.MEDIA_ERROR && recovered++ < 3) { h.recoverMediaError(); return }
|
||||||
|
if (d.type === Hls.ErrorTypes.NETWORK_ERROR && recovered++ < 3) { h.startLoad(v.currentTime); return }
|
||||||
|
setErr(`The video stopped loading (${d.details}).`)
|
||||||
|
})
|
||||||
|
h.loadSource(master)
|
||||||
|
h.attachMedia(v)
|
||||||
|
return () => { saveBW(h.bandwidthEstimate); h.destroy(); hls.current = null }
|
||||||
|
}
|
||||||
|
// Safari without Media Source (an older iPhone): its own HLS, Auto only.
|
||||||
|
v.src = master
|
||||||
|
v.addEventListener('loadedmetadata', start, { once: true })
|
||||||
|
return () => { v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
|
||||||
|
}, [open, choice === 'original' ? 'o' : 'h']) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// follow the re-encode while it runs
|
// A hand-picked quality, or back to Auto, while hls.js is already playing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'hls' || !stream) return
|
const h = hls.current
|
||||||
let stopPolling = false
|
if (!h || choice === 'original' || !h.levels.length) return
|
||||||
const tick = async () => { try { const st = await api.playStatus(stream.s.id); if (!stopPolling) { setStatus(st); if (st.running) setTimeout(tick, 2000) } } catch { /* ended */ } }
|
if (choice === 'auto') { h.currentLevel = -1; return }
|
||||||
tick()
|
const i = h.levels.findIndex((l) => l.width === choice)
|
||||||
return () => { stopPolling = true }
|
if (i >= 0) h.currentLevel = i
|
||||||
}, [mode, stream])
|
}, [choice])
|
||||||
|
|
||||||
const pick = (reason: string) => { setWhy(reason); setMode('pick') }
|
// What the machine is doing, while the menu is open.
|
||||||
const codecs = caps?.codecs ?? []
|
useEffect(() => {
|
||||||
|
if (!menu || !open) return
|
||||||
|
let live = true
|
||||||
|
const tick = () => api.playStatus(open.node, open.play.id).then((s) => { if (live) setStatus(s) }).catch(() => {})
|
||||||
|
tick()
|
||||||
|
const t = window.setInterval(tick, 2000)
|
||||||
|
return () => { live = false; window.clearInterval(t) }
|
||||||
|
}, [menu, open])
|
||||||
|
|
||||||
|
// Controls fade while it plays and nobody touches anything.
|
||||||
|
const poke = useCallback(() => {
|
||||||
|
setChrome(true)
|
||||||
|
window.clearTimeout(hideTimer.current)
|
||||||
|
hideTimer.current = window.setTimeout(() => { if (!video.current?.paused && !menuRef.current) setChrome(false) }, 3000)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => () => window.clearTimeout(hideTimer.current), [])
|
||||||
|
useEffect(() => { if (!menu) poke() }, [menu, poke])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const v = video.current
|
||||||
|
if (!v) return
|
||||||
|
if (v.paused) void v.play().catch(() => {})
|
||||||
|
else v.pause()
|
||||||
|
poke()
|
||||||
|
}, [poke])
|
||||||
|
const seekBy = useCallback((d: number) => {
|
||||||
|
const v = video.current
|
||||||
|
if (!v) return
|
||||||
|
v.currentTime = Math.max(0, Math.min(info?.duration ?? v.duration, v.currentTime + d))
|
||||||
|
poke()
|
||||||
|
}, [info, poke])
|
||||||
|
// Full screen: the browser's where it has one for an element, else (an iPhone) the window.
|
||||||
|
const fullscreen = useCallback(() => {
|
||||||
|
const el = box.current as (HTMLDivElement & { webkitRequestFullscreen?: () => void }) | null
|
||||||
|
if (!el) return
|
||||||
|
if (document.fullscreenElement) { void document.exitFullscreen(); return }
|
||||||
|
if (el.requestFullscreen) { void el.requestFullscreen().catch(() => setFull((f) => !f)); return }
|
||||||
|
if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); return }
|
||||||
|
setFull((f) => !f)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
const f = () => setFull(!!document.fullscreenElement)
|
||||||
|
document.addEventListener('fullscreenchange', f)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', f)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Keys work while the player has the focus (a click on it gives it).
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === ' ' || e.key === 'k') { e.preventDefault(); toggle() }
|
||||||
|
else if (e.key === 'ArrowLeft' || e.key === 'j') { e.preventDefault(); seekBy(e.key === 'j' ? -10 : -5) }
|
||||||
|
else if (e.key === 'ArrowRight' || e.key === 'l') { e.preventDefault(); seekBy(e.key === 'l' ? 10 : 5) }
|
||||||
|
else if (e.key === 'f') fullscreen()
|
||||||
|
else if (e.key === 'Escape') { if (menu) setMenu(false); else if (full && !document.fullscreenElement) setFull(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looking around a 360° video: a drag turns the view, a pinch zooms, a tap shows or hides the
|
||||||
|
// controls.
|
||||||
|
const pointers = useRef(new Map<number, { x: number; y: number }>())
|
||||||
|
const moved = useRef(0)
|
||||||
|
const pinch = useRef(0)
|
||||||
|
const onDown = (e: React.PointerEvent) => {
|
||||||
|
(e.target as Element).setPointerCapture(e.pointerId)
|
||||||
|
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||||
|
moved.current = 0
|
||||||
|
if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()]; pinch.current = Math.hypot(a.x - b.x, a.y - b.y) }
|
||||||
|
}
|
||||||
|
const onMove = (e: React.PointerEvent) => {
|
||||||
|
const p = pointers.current.get(e.pointerId)
|
||||||
|
if (!p) return
|
||||||
|
const dx = e.clientX - p.x, dy = e.clientY - p.y
|
||||||
|
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||||
|
moved.current += Math.abs(dx) + Math.abs(dy)
|
||||||
|
if (pointers.current.size === 2) {
|
||||||
|
const [a, b] = [...pointers.current.values()]
|
||||||
|
const d = Math.hypot(a.x - b.x, a.y - b.y)
|
||||||
|
if (pinch.current) sphere.current?.zoom(pinch.current / d)
|
||||||
|
pinch.current = d
|
||||||
|
} else sphere.current?.drag(dx, dy)
|
||||||
|
}
|
||||||
|
const onUp = (e: React.PointerEvent) => {
|
||||||
|
pointers.current.delete(e.pointerId)
|
||||||
|
if (pointers.current.size < 2) pinch.current = 0
|
||||||
|
if (pointers.current.size === 0 && moved.current < 6) {
|
||||||
|
if (e.pointerType === 'mouse') toggle()
|
||||||
|
else if (chrome && !menu) setChrome(false)
|
||||||
|
else poke()
|
||||||
|
setMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = video.current
|
||||||
|
const dur = info?.duration ?? 0
|
||||||
|
const shown = scrub ?? time
|
||||||
|
const rungs = open?.play.rungs ?? []
|
||||||
|
const label = (w: number) => rungs.find((r) => r.width === w)?.label ?? (w && w === info?.video?.width ? 'Original' : '')
|
||||||
|
const choiceLabel = choice === 'auto' ? `Auto${showing ? ` · ${label(showing)}` : ''}` : choice === 'original' ? 'Original' : label(choice)
|
||||||
|
const tooBig = vr && sphere.current && (info?.video?.width ?? 0) > sphere.current.maxTexture
|
||||||
|
const fileRate = info?.bit_rate || (info?.size && dur ? (info.size * 8) / dur : 0)
|
||||||
|
const keep = () => { if (v) resume.current = { at: v.currentTime, play: !v.paused } }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player">
|
<div ref={box} tabIndex={0} onKeyDown={onKey} className={`vp${chrome ? '' : ' bare'}${full ? ' max' : ''}`}
|
||||||
{mode === 'native' && (
|
onPointerMove={(e) => { if (e.pointerType === 'mouse') poke() }}
|
||||||
<>
|
onWheel={vr ? (e) => { if (!(e.target as Element).closest('.vp-menu')) sphere.current?.zoom(e.deltaY > 0 ? 1.08 : 1 / 1.08) } : undefined}>
|
||||||
<div className="play-bar"><span>as it is</span><span className="spacer" /><button className="btn" onClick={() => pick('')}>Re-encode…</button></div>
|
<video ref={video} className={vr ? 'vp-v vp-hidden' : 'vp-v'} playsInline preload="auto"
|
||||||
<div className="preview"><video controls playsInline preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => pick(`This browser can't play ${name} as it is — the container or codec isn't one it decodes.`)} /></div>
|
onPlay={() => { setPaused(false); poke() }} onPause={() => { setPaused(true); setChrome(true) }}
|
||||||
</>
|
onWaiting={() => setWaiting(true)} onPlaying={() => setWaiting(false)} onCanPlay={() => setWaiting(false)} onSeeking={() => setWaiting(true)} onSeeked={() => setWaiting(false)}
|
||||||
)}
|
onTimeUpdate={(e) => {
|
||||||
{mode === 'pick' && (
|
const el = e.currentTarget
|
||||||
<div className="play-pick notice">
|
setTime(el.currentTime)
|
||||||
<div>{why || `Re-encode ${name} on this machine and play that instead.`}</div>
|
resume.current = { at: el.currentTime, play: !el.paused }
|
||||||
{caps?.error && <div className="error" style={{ marginTop: 8 }}>{caps.error}</div>}
|
for (let i = 0; i < el.buffered.length; i++) if (el.buffered.start(i) <= el.currentTime + 0.5) setBuffered(el.buffered.end(i))
|
||||||
{!caps && !err && <div className="muted small" style={{ marginTop: 8 }}><Spinner /> asking the machine what it can encode…</div>}
|
}}
|
||||||
{caps && !caps.error && (
|
onClick={vr ? undefined : () => { if (matchMedia('(pointer: coarse)').matches) { if (chrome) setChrome(false); else poke() } else toggle() }}
|
||||||
<div className="row">
|
onDoubleClick={vr ? undefined : fullscreen} />
|
||||||
<label>codec
|
{vr && <canvas ref={canvas} className="vp-v vp-sphere" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onDoubleClick={fullscreen} />}
|
||||||
<select value={codec} onChange={(e) => setCodec(e.target.value)}>
|
|
||||||
{codecs.map((c) => <option key={c.id} value={c.id} disabled={!c.engine}>{c.label}{c.engine ? ` — ${c.engine}` : ' — not on this machine'}</option>)}
|
{(waiting || !open) && !err && <div className="vp-spin" aria-label="Loading" />}
|
||||||
</select>
|
{err && <div className="vp-err"><p>{err}</p></div>}
|
||||||
</label>
|
{open && paused && !waiting && !err && <button className="vp-big" onClick={toggle} aria-label="Play"><I d={PLAY} size={36} fill /></button>}
|
||||||
<label>size
|
{note && <div className="vp-note" role="status" onClick={() => setNote('')}>{note}</div>}
|
||||||
<select value={quality} onChange={(e) => setQuality(e.target.value)} disabled={codec === 'copy'}>
|
{vr && open && chrome && !menu && time < 8 && !err && <div className="vp-hint">Drag to look around · scroll or pinch to zoom</div>}
|
||||||
{caps.qualities.map((q) => <option key={q.id} value={q.id}>{q.label}</option>)}
|
|
||||||
</select>
|
<div className="vp-bottom">
|
||||||
</label>
|
<div className="vp-seek" style={{ ['--p' as string]: dur ? `${(shown / dur) * 100}%` : '0%', ['--b' as string]: dur ? `${(buffered / dur) * 100}%` : '0%' }}>
|
||||||
<Btn className="btn on" disabled={!codecs.find((c) => c.id === codec)?.engine} onClick={start}>Play</Btn>
|
<input type="range" min={0} max={dur || 1} step={0.1} value={shown} aria-label="Seek"
|
||||||
<a className="btn" href={api.fileDownloadURL(path, sudo)} download={name}>Download instead</a>
|
onChange={(e) => { setScrub(Number(e.target.value)); poke() }}
|
||||||
|
onPointerUp={(e) => { if (v) v.currentTime = Number((e.target as HTMLInputElement).value); setScrub(null) }}
|
||||||
|
onKeyUp={(e) => { if (v) v.currentTime = Number((e.target as HTMLInputElement).value); setScrub(null) }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="vp-row">
|
||||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
<button className="vp-icon" onClick={toggle} aria-label={paused ? 'Play' : 'Pause'}>{paused ? <I d={PLAY} fill /> : <I d={PAUSE} />}</button>
|
||||||
</div>
|
<span className="vp-time">{clock(shown)} / {clock(dur)}</span>
|
||||||
)}
|
|
||||||
{mode === 'hls' && stream && (
|
|
||||||
<>
|
|
||||||
<div className="play-bar">
|
|
||||||
<span title={describe(stream.s)}>{describe(stream.s)}</span>
|
|
||||||
{status && status.running && (stream.s.seekable
|
|
||||||
? <span>· {status.speed ? `${status.speed.toFixed(1)}× ` : ''}from {clock(status.from ?? 0)} · {Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% ready — seek anywhere</span>
|
|
||||||
: <span>· {status.speed ? `${status.speed.toFixed(1)}×` : ''} {stream.s.source.duration ? `${Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% through` : `${Math.round(status.progress)} s done`} — seek within what's done</span>)}
|
|
||||||
{status?.done && <span>· all of it is ready</span>}
|
|
||||||
{status?.error && <span className="error">· stopped early: {status.error}</span>}
|
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="btn" onClick={() => { stop(); setMode('pick'); setWhy('') }}>Change…</button>
|
{vr && canMotion() && (
|
||||||
|
<button className={`vp-icon${motion ? ' on' : ''}`} aria-label="Look around by moving the phone" aria-pressed={motion}
|
||||||
|
onClick={async () => { const s = sphere.current; if (s) setMotion(await s.setGyro(!s.gyroOn)) }}><I d={PHONE} /></button>
|
||||||
|
)}
|
||||||
|
<button className="vp-q" onClick={() => { setMenu((m) => !m); poke() }} aria-label="Quality" aria-expanded={menu}><I d={TUNE} size={16} /><span>{choiceLabel}</span></button>
|
||||||
|
<button className="vp-icon" onClick={fullscreen} aria-label={full ? 'Exit full screen' : 'Full screen'}><I d={full ? SMALL : FULL} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menu && open && (
|
||||||
|
<div className="vp-menu" role="menu">
|
||||||
|
<div className="vp-menu-h">Quality</div>
|
||||||
|
<button role="menuitemradio" aria-checked={choice === 'auto'} onClick={() => { setChoice('auto'); setMenu(false) }}>
|
||||||
|
<span className="ck">{choice === 'auto' && <I d={CHECK} size={16} />}</span><span>Auto</span><small>{choice === 'auto' && showing ? label(showing) : 'fits the connection'}</small>
|
||||||
|
</button>
|
||||||
|
<button role="menuitemradio" aria-checked={choice === 'original'} disabled={!!tooBig} onClick={() => { keep(); setChoice('original'); setMenu(false) }}>
|
||||||
|
<span className="ck">{choice === 'original' && <I d={CHECK} size={16} />}</span><span>Original</span>
|
||||||
|
<small>{tooBig ? 'too big for this device' : [size(info?.video?.width, info?.video?.height, vr), fileRate ? mbps(fileRate) : ''].filter(Boolean).join(' · ')}</small>
|
||||||
|
</button>
|
||||||
|
{rungs.map((r) => (
|
||||||
|
<button key={r.name} role="menuitemradio" aria-checked={choice === r.width} onClick={() => { keep(); setChoice(r.width); setMenu(false) }}>
|
||||||
|
<span className="ck">{choice === r.width && <I d={CHECK} size={16} />}</span><span>{r.label}</span><small>{mbps(r.bitrate)}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="vp-menu-f">
|
||||||
|
Made on <b>{open.host}</b> · {status?.engine || open.engine}
|
||||||
|
{status?.engine && <> · {status.paused ? 'far enough ahead, waiting' : `from ${clock(status.from)}`}</>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="preview"><video ref={video} controls autoPlay playsInline /></div>
|
|
||||||
{err && <div className="error" style={{ margin: 12 }}>{err}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+58
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { fetchCatalog } from './sensors'
|
import { fetchCatalog } from './sensors'
|
||||||
import { api, Me, TokenInfo, UpdateStatus } from './api'
|
import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api'
|
||||||
import { savePrefs } from './accents'
|
import { savePrefs } from './accents'
|
||||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||||
@@ -104,6 +104,7 @@ export default function Settings({ me }: { me: Me }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<TranscodeDevice />
|
||||||
|
|
||||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -248,3 +249,59 @@ function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { n
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where videos played in Files are made: one device on one machine, for every machine's files.
|
||||||
|
// Every machine's devices are asked for live; one that can't be used says why.
|
||||||
|
function TranscodeDevice() {
|
||||||
|
const [t, setT] = useState<TranscodeSetting | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
useEffect(() => { api.transcode().then(setT).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||||
|
const set = async (node: string, device: string) => {
|
||||||
|
setBusy(node + '|' + device); setErr('')
|
||||||
|
try { setT(await api.setTranscode(node, device)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
const sel = t?.selected
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Transcode device</h2><span className="spacer" /><span className="muted small">videos in Files, on every machine</span></div>
|
||||||
|
<section className="card">
|
||||||
|
{!t && !err && <div className="muted small"><Spinner /> asking every machine what it can encode on…</div>}
|
||||||
|
{t && (
|
||||||
|
<div className="tc-list">
|
||||||
|
<div className="tc-machine">
|
||||||
|
<div className="tc-host">Anywhere</div>
|
||||||
|
<div className="chips">
|
||||||
|
<button className={'chip' + (!sel ? ' on' : '')} disabled={!!busy} onClick={() => set('', '')}>The machine the file is on, its best device</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{t.machines.map((m) => (
|
||||||
|
<div key={m.node} className="tc-machine">
|
||||||
|
<div className="tc-host">{m.host}{m.node === '' && <span className="muted small"> · this hub</span>}</div>
|
||||||
|
{m.error && <div className="muted small">{m.error}</div>}
|
||||||
|
<div className="chips">
|
||||||
|
{(m.devices ?? []).map((d) => {
|
||||||
|
const on = !!sel && sel.node === m.node && sel.device === d.id
|
||||||
|
return (
|
||||||
|
<button key={d.id} className={'chip' + (on ? ' on' : '') + (d.ok ? '' : ' off')} disabled={!d.ok || !!busy} title={d.ok ? d.id : d.reason}
|
||||||
|
onClick={() => set(m.node, d.id)}>
|
||||||
|
{busy === m.node + '|' + d.id ? <Spinner size={10} /> : null}{d.name}{d.id !== 'cpu' && d.integrated ? ' (integrated)' : ''}{!d.ok && d.reason ? ` — ${d.reason}` : ''}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||||
|
<div className="muted small" style={{ marginTop: 10 }}>
|
||||||
|
Opening a video in Files plays it YouTube's way — Auto picks the quality the connection takes, any quality can be picked by hand,
|
||||||
|
or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. When it is on another
|
||||||
|
machine than the file, that machine reads the file through this hub. If it can't be reached, the file's own machine makes it instead.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+25
-12
@@ -4,7 +4,12 @@ export class Unauthenticated extends Error { constructor(msg = 'unauthenticated'
|
|||||||
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
||||||
// the machine list itself) pass local=true and are never rewritten.
|
// the machine list itself) pass local=true and are never rewritten.
|
||||||
let base = ''
|
let base = ''
|
||||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : ''; machine = name ?? '' }
|
||||||
|
let machine = ''
|
||||||
|
// The machine the console is looking at: '' is this host.
|
||||||
|
export const currentMachine = () => machine
|
||||||
|
// A path under another machine's /api, whichever one the console is looking at.
|
||||||
|
export const nodeAPI = (node: string, p: string) => (node && p.startsWith('/api/') ? `/api/nodes/${node}${p.slice(4)}` : p)
|
||||||
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
||||||
|
|
||||||
// A machine that just failed to answer is left alone for a few seconds instead of
|
// A machine that just failed to answer is left alone for a few seconds instead of
|
||||||
@@ -250,12 +255,18 @@ export interface ARCDetail {
|
|||||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
||||||
|
|
||||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | 'audio' }
|
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | 'audio' }
|
||||||
export interface PlayCaps { codecs: { id: string; label: string; engine?: string }[]; qualities: { id: string; label: string; height: number }[]; error?: string }
|
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number; spherical?: boolean }
|
||||||
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number }
|
export interface PlayRung { name: string; label: string; width: number; height: number; fps: number; bitrate: number; peak: number }
|
||||||
export interface PlayStream { id: string; codec: string; quality: string; engine: string; width: number; height: number; kbps: number; seekable?: boolean; source: { container: string; duration: number; bit_rate?: number; video?: PlayTrack; audio?: PlayTrack } }
|
export interface PlayStatus { engine?: string; quality?: string; from: number; ahead: number; paused?: boolean }
|
||||||
// progress: seconds written (repackaged), or seconds of the film on disk (a seekable re-encode,
|
// A play: where it is made (node, '' the hub), its qualities best first, and the playlist under
|
||||||
// whose run now going started at `from`)
|
// that machine's /api.
|
||||||
export interface PlayStatus { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number; from?: number }
|
export interface PlayOpen {
|
||||||
|
node: string; master: string; engine: string; host: string; note?: string; status: PlayStatus
|
||||||
|
play: { id: string; name: string; device: string; remote: boolean; rungs: PlayRung[]; info: { container: string; duration: number; bit_rate?: number; size?: number; video?: PlayTrack; audio?: PlayTrack } }
|
||||||
|
}
|
||||||
|
export interface TranscodeDevice { id: string; name: string; integrated?: boolean; ok: boolean; reason?: string }
|
||||||
|
export interface TranscodeMachine { node: string; host: string; devices?: TranscodeDevice[]; error?: string }
|
||||||
|
export interface TranscodeSetting { selected: { node: string; device: string } | null; machines: TranscodeMachine[] }
|
||||||
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
||||||
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
||||||
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
||||||
@@ -281,6 +292,13 @@ export const api = {
|
|||||||
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
|
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
|
||||||
return r.text()
|
return r.text()
|
||||||
},
|
},
|
||||||
|
// Playing: opened on the hub, which picks the machine (the Transcode device setting); the
|
||||||
|
// rest goes to that machine.
|
||||||
|
play: (path: string, sudo: boolean) => req<PlayOpen>('POST', '/api/play', { node: currentMachine(), path, sudo }, true),
|
||||||
|
playStatus: (node: string, id: string) => req<PlayStatus>('GET', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||||
|
playStop: (node: string, id: string) => req<void>('DELETE', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||||
|
transcode: () => req<TranscodeSetting>('GET', '/api/transcode', undefined, true),
|
||||||
|
setTranscode: (node: string, device: string) => req<TranscodeSetting>('PUT', '/api/transcode', device ? { node, device } : {}, true),
|
||||||
fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
|
fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
|
||||||
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
|
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
|
||||||
// v carries the file's mtime and size, so a changed file is a new URL and an unchanged one a cached one
|
// v carries the file's mtime and size, so a changed file is a new URL and an unchanged one a cached one
|
||||||
@@ -288,11 +306,6 @@ export const api = {
|
|||||||
thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`),
|
thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`),
|
||||||
thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'),
|
thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'),
|
||||||
thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'),
|
thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'),
|
||||||
// playback of what the browser can't play as it is: an HLS re-encode on the machine
|
|
||||||
playCaps: (sudo: boolean) => req<PlayCaps>('GET', `/api/files/play-caps${sudo ? '?sudo=1' : ''}`),
|
|
||||||
playStart: (path: string, codec: string, quality: string, sudo: boolean) => req<{ stream: PlayStream; url: string }>('POST', `/api/files/play${sudo ? '?sudo=1' : ''}`, { path, codec, quality }),
|
|
||||||
playStatus: (id: string) => req<PlayStatus>('GET', `/api/files/play/${id}`),
|
|
||||||
playStop: (id: string) => req<void>('DELETE', `/api/files/play/${id}`),
|
|
||||||
fileWrite: async (path: string, text: string, sudo: boolean) => {
|
fileWrite: async (path: string, text: string, sudo: boolean) => {
|
||||||
const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
|
const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||||
if (r.status === 401) throw new Unauthenticated()
|
if (r.status === 401) throw new Unauthenticated()
|
||||||
|
|||||||
+45
-7
@@ -145,6 +145,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|||||||
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||||
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||||
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
||||||
|
.chip.off { opacity: .55; cursor: default; } .chip.off:hover { color: var(--muted); border-color: var(--border); }
|
||||||
|
.tc-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.tc-host { font-weight: 600; margin-bottom: 6px; }
|
||||||
.svc-rows { flex: 1; overflow-y: auto; }
|
.svc-rows { flex: 1; overflow-y: auto; }
|
||||||
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||||
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
@@ -365,13 +368,48 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
||||||
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
||||||
.player { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||||
.player .preview { flex: 1; min-height: 0; }
|
on an iPhone the whole window */
|
||||||
.play-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 12px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
|
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||||
.play-bar select { padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12px; }
|
.vp.max { position: fixed; inset: 0; z-index: 1000; }
|
||||||
.play-pick { margin: 12px; }
|
.vp-v { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
|
||||||
.play-pick .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
|
.vp-hidden { opacity: 0; pointer-events: none; }
|
||||||
.play-pick label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
.vp-sphere { touch-action: none; cursor: grab; }
|
||||||
|
.vp-sphere:active { cursor: grabbing; }
|
||||||
|
.vp-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 26px 8px calc(env(safe-area-inset-bottom) + 4px); background: linear-gradient(transparent, rgba(0, 0, 0, .75)); transition: opacity .25s; z-index: 2; }
|
||||||
|
.vp.bare .vp-bottom, .vp.bare .vp-hint { opacity: 0; pointer-events: none; }
|
||||||
|
.vp.bare { cursor: none; }
|
||||||
|
.vp-row { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.vp-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 19px; color: #fff; }
|
||||||
|
.vp-icon:hover { background: rgba(255, 255, 255, .14); }
|
||||||
|
.vp-icon.on { color: var(--accent); }
|
||||||
|
.vp-time { font-size: 13px; font-variant-numeric: tabular-nums; padding: 0 6px; white-space: nowrap; }
|
||||||
|
.vp-q { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 17px; font-size: 13px; font-weight: 600; white-space: nowrap; color: #fff; }
|
||||||
|
.vp-q:hover { background: rgba(255, 255, 255, .14); }
|
||||||
|
.vp-seek { position: relative; height: 24px; margin: 0 8px; display: flex; align-items: center; }
|
||||||
|
.vp-seek::before { content: ''; position: absolute; left: 0; right: 0; height: 4px; border-radius: 2px;
|
||||||
|
background: linear-gradient(to right, var(--accent) var(--p), rgba(255, 255, 255, .45) var(--p) var(--b), rgba(255, 255, 255, .2) var(--b)); }
|
||||||
|
.vp-seek input { position: relative; width: 100%; margin: 0; background: transparent; border: 0; padding: 0; height: 24px; -webkit-appearance: none; appearance: none; cursor: pointer; }
|
||||||
|
.vp-seek input::-webkit-slider-runnable-track { height: 4px; background: transparent; }
|
||||||
|
.vp-seek input::-moz-range-track { height: 4px; background: transparent; }
|
||||||
|
.vp-seek input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; margin-top: -5px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||||
|
.vp-seek input::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||||
|
.vp-big { position: absolute; left: 50%; top: 50%; width: 68px; height: 68px; margin: -34px 0 0 -34px; border-radius: 50%; display: grid; place-items: center; background: rgba(0, 0, 0, .55); color: #fff; z-index: 2; padding-left: 4px; }
|
||||||
|
.vp-spin { position: absolute; left: 50%; top: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .2); border-top-color: var(--accent); animation: vp-spin .8s linear infinite; z-index: 1; pointer-events: none; }
|
||||||
|
@keyframes vp-spin { to { transform: rotate(360deg); } }
|
||||||
|
.vp-err { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; z-index: 1; }
|
||||||
|
.vp-err p { margin: 0; max-width: 460px; line-height: 1.45; }
|
||||||
|
.vp-note { position: absolute; left: 50%; top: 14px; transform: translateX(-50%); max-width: min(92%, 480px); padding: 9px 13px; border-radius: 8px; background: rgba(20, 20, 20, .92); font-size: 13px; line-height: 1.4; z-index: 3; cursor: pointer; }
|
||||||
|
.vp-hint { position: absolute; left: 50%; bottom: 86px; transform: translateX(-50%); padding: 5px 11px; border-radius: 14px; background: rgba(0, 0, 0, .55); font-size: 12px; pointer-events: none; transition: opacity .25s; z-index: 1; white-space: nowrap; }
|
||||||
|
.vp-menu { position: absolute; right: 8px; bottom: 60px; width: min(290px, calc(100% - 16px)); max-height: calc(100% - 76px); overflow-y: auto; padding: 6px; border-radius: 10px; background: rgba(24, 24, 24, .96); box-shadow: 0 8px 30px rgba(0, 0, 0, .5); z-index: 4; }
|
||||||
|
.vp-menu-h { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: rgba(255, 255, 255, .55); padding: 6px 10px 4px; }
|
||||||
|
.vp-menu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border-radius: 6px; text-align: left; font-size: 14px; color: #fff; }
|
||||||
|
.vp-menu button:hover:not(:disabled) { background: rgba(255, 255, 255, .1); }
|
||||||
|
.vp-menu button:disabled { opacity: .45; }
|
||||||
|
.vp-menu button small { margin-left: auto; color: rgba(255, 255, 255, .6); font-size: 12px; white-space: nowrap; }
|
||||||
|
.vp-menu .ck { width: 16px; flex: none; color: var(--accent); display: grid; place-items: center; }
|
||||||
|
.vp-menu-f { border-top: 1px solid rgba(255, 255, 255, .12); margin-top: 6px; padding: 8px 10px 4px; font-size: 12px; color: rgba(255, 255, 255, .6); line-height: 1.4; }
|
||||||
|
.vp-menu-f b { color: #fff; font-weight: 600; }
|
||||||
.preview.audio { align-items: flex-start; }
|
.preview.audio { align-items: flex-start; }
|
||||||
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
|
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
|
||||||
.audio-box .ficon { font-size: 48px; }
|
.audio-box .ficon { font-size: 48px; }
|
||||||
|
|||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
// The 360° view: an equirectangular video drawn onto a canvas as the flat picture a person
|
||||||
|
// would see looking out from the middle of it. Drag (or swipe) to look around, pinch or scroll
|
||||||
|
// to zoom, and on a phone, optionally, turn the phone. One full-canvas triangle pair; the
|
||||||
|
// fragment shader works out, for each pixel, which way it looks and where that is in the video.
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
attribute vec2 a;
|
||||||
|
varying vec2 p;
|
||||||
|
void main() { p = a; gl_Position = vec4(a, 0.0, 1.0); }`
|
||||||
|
|
||||||
|
const FRAG = `
|
||||||
|
precision highp float;
|
||||||
|
uniform sampler2D tex;
|
||||||
|
uniform mat3 view; // columns: right, up, forward
|
||||||
|
uniform vec2 aspect; // the screen's sides over its taller side: (w/h, 1) or (1, h/w)
|
||||||
|
uniform float fov; // radians, across the taller side
|
||||||
|
varying vec2 p;
|
||||||
|
const float PI = 3.14159265358979;
|
||||||
|
void main() {
|
||||||
|
vec2 q = p * aspect;
|
||||||
|
// Narrow: an ordinary lens. Wide: stereographic, which goes on past 180 degrees to the
|
||||||
|
// "little planet". Between 100 and 140 degrees one turns into the other.
|
||||||
|
vec3 rect = normalize(vec3(q * tan(min(fov, 3.0) * 0.5), 1.0));
|
||||||
|
float r = length(q) * 2.0 * tan(fov * 0.25);
|
||||||
|
float th = 2.0 * atan(r * 0.5);
|
||||||
|
vec2 dir = r > 0.0 ? q / length(q) : vec2(0.0);
|
||||||
|
vec3 stereo = vec3(dir * sin(th), cos(th));
|
||||||
|
float t = smoothstep(1.745, 2.443, fov);
|
||||||
|
vec3 d = normalize(view * normalize(mix(rect, stereo, t)));
|
||||||
|
float lon = atan(d.x, -d.z);
|
||||||
|
float lat = asin(clamp(d.y, -1.0, 1.0));
|
||||||
|
gl_FragColor = texture2D(tex, vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI));
|
||||||
|
}`
|
||||||
|
|
||||||
|
// From a close look at something far off to the whole sphere as a little planet.
|
||||||
|
const MIN_FOV = 8, MAX_FOV = 300
|
||||||
|
|
||||||
|
type Vec3 = [number, number, number]
|
||||||
|
type Quat = [number, number, number, number] // x, y, z, w
|
||||||
|
|
||||||
|
function qmul(a: Quat, b: Quat): Quat {
|
||||||
|
const [ax, ay, az, aw] = a, [bx, by, bz, bw] = b
|
||||||
|
return [aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz]
|
||||||
|
}
|
||||||
|
function qrot(q: Quat, v: Vec3): Vec3 {
|
||||||
|
const [x, y, z, w] = q
|
||||||
|
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
|
||||||
|
return [ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x]
|
||||||
|
}
|
||||||
|
const axis = (v: Vec3, rad: number): Quat => { const s = Math.sin(rad / 2); return [v[0] * s, v[1] * s, v[2] * s, Math.cos(rad / 2)] }
|
||||||
|
|
||||||
|
// The phone's orientation as a camera's, the way the W3C spec defines the angles: Z-X'-Y''
|
||||||
|
// intrinsic, then turned to look out of the back of the phone, then for the screen's rotation.
|
||||||
|
function deviceQuat(alpha: number, beta: number, gamma: number, screen: number): Quat {
|
||||||
|
const d = Math.PI / 180
|
||||||
|
const [a, b, g] = [alpha * d, beta * d, gamma * d]
|
||||||
|
let q = qmul(qmul(axis([0, 1, 0], a), axis([1, 0, 0], b)), axis([0, 0, 1], -g)) // YXZ, in three.js's frame
|
||||||
|
q = qmul(q, [-Math.SQRT1_2, 0, 0, Math.SQRT1_2])
|
||||||
|
return qmul(q, axis([0, 0, 1], -screen * d))
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sphere {
|
||||||
|
yaw = 0 // degrees, + is right
|
||||||
|
pitch = 0 // degrees, + is up
|
||||||
|
fov = 80 // degrees, the taller of the two sides
|
||||||
|
private gl: WebGLRenderingContext
|
||||||
|
private tex: WebGLTexture
|
||||||
|
private uView: WebGLUniformLocation
|
||||||
|
private uAspect: WebGLUniformLocation
|
||||||
|
private uFov: WebGLUniformLocation
|
||||||
|
private raf = 0
|
||||||
|
private frameCb = 0
|
||||||
|
private dirty = true
|
||||||
|
private hasFrame = false
|
||||||
|
private gyro: { alpha: number; beta: number; gamma: number } | null = null
|
||||||
|
private gyroYaw = 0 // what dragging adds to the phone's own heading
|
||||||
|
private ro: ResizeObserver
|
||||||
|
maxTexture: number
|
||||||
|
|
||||||
|
constructor(private canvas: HTMLCanvasElement, private video: HTMLVideoElement) {
|
||||||
|
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, preserveDrawingBuffer: false })
|
||||||
|
if (!gl) throw new Error('This browser can’t draw 360° video (no WebGL).')
|
||||||
|
this.gl = gl
|
||||||
|
this.maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
|
||||||
|
const sh = (type: number, src: string) => {
|
||||||
|
const s = gl.createShader(type)!
|
||||||
|
gl.shaderSource(s, src); gl.compileShader(s)
|
||||||
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader')
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
const prog = gl.createProgram()!
|
||||||
|
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG))
|
||||||
|
gl.linkProgram(prog); gl.useProgram(prog)
|
||||||
|
const buf = gl.createBuffer()
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
|
||||||
|
const loc = gl.getAttribLocation(prog, 'a')
|
||||||
|
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
|
||||||
|
this.tex = gl.createTexture()!
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.tex)
|
||||||
|
// No mipmaps (a video changes every frame) and clamped: any size of picture will do.
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0]))
|
||||||
|
this.uView = gl.getUniformLocation(prog, 'view')!
|
||||||
|
this.uAspect = gl.getUniformLocation(prog, 'aspect')!
|
||||||
|
this.uFov = gl.getUniformLocation(prog, 'fov')!
|
||||||
|
this.ro = new ResizeObserver(() => { this.dirty = true })
|
||||||
|
this.ro.observe(canvas)
|
||||||
|
this.watchFrames()
|
||||||
|
const loop = () => { this.draw(); this.raf = requestAnimationFrame(loop) }
|
||||||
|
this.raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new picture from the video: on the frame callback where there is one, else every draw
|
||||||
|
// while it plays.
|
||||||
|
private watchFrames() {
|
||||||
|
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }
|
||||||
|
if (v.requestVideoFrameCallback) {
|
||||||
|
const cb = () => { this.upload(); this.frameCb = v.requestVideoFrameCallback!(cb) }
|
||||||
|
this.frameCb = v.requestVideoFrameCallback(cb)
|
||||||
|
}
|
||||||
|
v.addEventListener('loadeddata', this.upload)
|
||||||
|
v.addEventListener('seeked', this.upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private upload = () => {
|
||||||
|
const v = this.video
|
||||||
|
if (v.readyState < 2 || !v.videoWidth) return
|
||||||
|
const gl = this.gl
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.tex)
|
||||||
|
try {
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, v)
|
||||||
|
this.hasFrame = true
|
||||||
|
this.dirty = true
|
||||||
|
} catch { /* not ready yet */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw() {
|
||||||
|
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: unknown }
|
||||||
|
if (!v.requestVideoFrameCallback && !v.paused) this.upload()
|
||||||
|
if (!this.dirty && !this.gyro) return
|
||||||
|
this.dirty = false
|
||||||
|
const c = this.canvas, dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr)
|
||||||
|
if (!w || !h) return
|
||||||
|
if (c.width !== w || c.height !== h) { c.width = w; c.height = h }
|
||||||
|
const gl = this.gl
|
||||||
|
gl.viewport(0, 0, w, h)
|
||||||
|
if (!this.hasFrame) { gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return }
|
||||||
|
// The field of view applies to the taller side; on a phone held upright that is the height.
|
||||||
|
let right: Vec3, up: Vec3, fwd: Vec3
|
||||||
|
if (this.gyro) {
|
||||||
|
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
|
||||||
|
right = qrot(q, [1, 0, 0]); up = qrot(q, [0, 1, 0]); fwd = qrot(q, [0, 0, -1])
|
||||||
|
} else {
|
||||||
|
const y = (this.yaw * Math.PI) / 180, p = (this.pitch * Math.PI) / 180
|
||||||
|
fwd = [Math.sin(y) * Math.cos(p), Math.sin(p), -Math.cos(y) * Math.cos(p)]
|
||||||
|
right = [Math.cos(y), 0, Math.sin(y)]
|
||||||
|
up = [right[1] * fwd[2] - right[2] * fwd[1], right[2] * fwd[0] - right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]]
|
||||||
|
}
|
||||||
|
// d = right·x + up·y + fwd·1, column-major
|
||||||
|
gl.uniformMatrix3fv(this.uView, false, new Float32Array([...right, ...up, ...fwd]))
|
||||||
|
gl.uniform2f(this.uAspect, w >= h ? 1 : w / h, w >= h ? h / w : 1)
|
||||||
|
gl.uniform1f(this.uFov, (this.fov * Math.PI) / 180)
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging by a pixel turns the view by the angle a pixel covers, so what is under the finger
|
||||||
|
// stays under it.
|
||||||
|
drag(dx: number, dy: number) {
|
||||||
|
const per = this.fov / Math.max(this.canvas.clientWidth, this.canvas.clientHeight)
|
||||||
|
if (this.gyro) this.gyroYaw = (this.gyroYaw - dx * per) % 360
|
||||||
|
else {
|
||||||
|
this.yaw = (this.yaw - dx * per) % 360
|
||||||
|
this.pitch = Math.max(-89, Math.min(89, this.pitch + dy * per))
|
||||||
|
}
|
||||||
|
this.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
zoom(factor: number) {
|
||||||
|
this.fov = Math.max(MIN_FOV, Math.min(MAX_FOV, this.fov * factor))
|
||||||
|
this.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
get gyroOn() { return this.gyro !== null }
|
||||||
|
|
||||||
|
private onOrient = (e: DeviceOrientationEvent) => {
|
||||||
|
if (e.alpha === null || e.beta === null || e.gamma === null) return
|
||||||
|
if (!this.gyro) {
|
||||||
|
// Keep looking where the drag left off: the phone's heading now is the drag's yaw.
|
||||||
|
const q = deviceQuat(e.alpha, e.beta, e.gamma, screenAngle())
|
||||||
|
const f = qrot(q, [0, 0, -1])
|
||||||
|
this.gyroYaw = this.yaw - (Math.atan2(f[0], -f[2]) * 180) / Math.PI
|
||||||
|
}
|
||||||
|
this.gyro = { alpha: e.alpha, beta: e.beta, gamma: e.gamma }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning on motion asks for permission on an iPhone, which must happen in a tap.
|
||||||
|
async setGyro(on: boolean): Promise<boolean> {
|
||||||
|
if (!on) {
|
||||||
|
window.removeEventListener('deviceorientation', this.onOrient)
|
||||||
|
if (this.gyro) {
|
||||||
|
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
|
||||||
|
const f = qrot(q, [0, 0, -1])
|
||||||
|
this.yaw = (Math.atan2(f[0], -f[2]) * 180) / Math.PI
|
||||||
|
this.pitch = (Math.asin(Math.max(-1, Math.min(1, f[1]))) * 180) / Math.PI
|
||||||
|
}
|
||||||
|
this.gyro = null
|
||||||
|
this.dirty = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const D = DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<string> }
|
||||||
|
if (D.requestPermission) {
|
||||||
|
try { if ((await D.requestPermission()) !== 'granted') return false } catch { return false }
|
||||||
|
}
|
||||||
|
window.addEventListener('deviceorientation', this.onOrient)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
cancelAnimationFrame(this.raf)
|
||||||
|
const v = this.video as HTMLVideoElement & { cancelVideoFrameCallback?: (n: number) => void }
|
||||||
|
if (this.frameCb && v.cancelVideoFrameCallback) v.cancelVideoFrameCallback(this.frameCb)
|
||||||
|
v.removeEventListener('loadeddata', this.upload)
|
||||||
|
v.removeEventListener('seeked', this.upload)
|
||||||
|
window.removeEventListener('deviceorientation', this.onOrient)
|
||||||
|
this.ro.disconnect()
|
||||||
|
this.gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenAngle(): number {
|
||||||
|
const o = screen.orientation?.angle
|
||||||
|
if (typeof o === 'number') return o
|
||||||
|
return (window as unknown as { orientation?: number }).orientation ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canMotion = () => typeof DeviceOrientationEvent !== 'undefined' && matchMedia('(pointer: coarse)').matches
|
||||||
Reference in New Issue
Block a user