3 Commits
Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5.5 d1b3ef7efd PLAN: the Ham Share player and the Transcode device setting; e2e-player.mjs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:00:32 -07:00
Mitchell-JohnsonandClaude Opus 5.5 8c69826547 files: the player turns a phone's picture upright on the GPU, and shows HLG as it is
tonemap_vaapi refuses HLG, and the hardware path never honoured a rotation
(ffmpeg's autorotate is for frames in memory). HDR10 is still tone-mapped,
on the card and, failing that, in software before the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:56:16 -07:00
Mitchell-JohnsonandClaude Opus 5.5 fc4126a0ed 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>
2026-10-23 14:52:35 -07:00
21 changed files with 2437 additions and 1495 deletions
+45
View File
@@ -810,6 +810,51 @@ something long in one, switch away and back, refresh — it's all still there.
- Tested (`TestSeekRestartsTheEncode`): a 64 s 1080p clip, a request for segment 12 right - Tested (`TestSeekRestartsTheEncode`): a 64 s 1080p clip, a request for segment 12 right
after the start runs from 48 s, the gap is filled, all 16 segments joined behind the one after the start runs from 48 s, the gap is filled, all 16 segments joined behind the one
init decode with no hole. init decode with no hole.
- **The player is Ham Share's; a Transcode device setting** — **2026-10-23** (v0.1.0-226,
`internal/media/{encode,play}.go`, `internal/api/play.go`, `Player.tsx`, `vr.ts`,
`Settings.tsx`). Asked for as "redo the HamLab one with the one you sent … a 'Transcode
Device' setting … the A380 on Grid selected … other options being the other transcode
devices like the iGPUs on the various CPUs that have HamLab installed". **Replaces** the
previous two entries' design: no codec/size picker, no single-quality re-encode, no
repackaging; the old streamer (`hls.go`) is gone.
- **YouTube's way:** Auto (hls.js reads the connection), any quality by hand, or Original
(the file as it is, `/api/files/read`, falling back to Auto when the browser can't). A
ladder by size (`Ladder`): flat 2160p/1440p/1080p/720p/480p by the short side, plus the
file's own size on top when it sits between steps (a 2292×960 capture gets 960p); 360
files by width, named as YouTube does (2160s 3840 … 720s). 720 and down at ≤30 fps.
H.264 High + AAC only.
- **Pieces made when asked for**, 3 s MPEG-TS, every quality's playlist written whole up
front. One ffmpeg per play: it starts at the piece asked, runs ahead, and is paused
(SIGSTOP; a root run through `run-ffmpeg`, which now reads `stop`/`cont` on stdin) 10
pieces ahead; a piece more than 3 past what's made, or at another quality, starts a new
run there. Keyframes only on the 3 s marks and every run's times offset alike
(`-output_ts_offset start+10`), so pieces from any run and quality join (`TestPieces`
checks it; `SRC=… DEVICE=… go test ./internal/media -run Pieces` on a machine with a GPU).
At most 3 runs a machine, the one asked of least lately ended for a fourth; 2 GiB of
pieces a play; forgotten after 30 min.
- **hls.js is patched** (`PatientAbr`): the wait for a piece being made isn't counted
against the connection, and a piece isn't abandoned before its first byte. Without it
Auto dropped to the bottom after every seek.
- **Transcode device** (Settings, hub-side, `store.Transcode`): every machine's devices
asked live (`GET /api/files/play-devices` on each), unusable ones greyed with the reason
(hamtv's UHD 600: the render permission; mac and gate: no ffmpeg). Set to **grid's Arc
A380**. "The machine the file is on" is the other choice. `POST /api/play` on the hub
opens the play where the setting says; **a machine transcoding another's file reads it
through the hub** at `/api/play-src/<token>` — a random token good for that one file, 12
h from its last use, ranges passed through to the file's machine (ffmpeg reconnects).
If that machine fails, the file's own makes it and the player says so.
- Recipes per device: same-card decode first, then CPU decode + upload, then libx264 as
the fallback whatever was asked. A card without `vainfo` (core) is tried anyway: core's
UHD encodes. **Rotation:** the card path turns a phone's picture itself
(`transpose_vaapi`, `-noautorotate`; ffmpeg's autorotate never applied to VAAPI frames,
so iPhone clips came out sideways). **HLG** is shown as it is (tonemap_vaapi refuses it);
HDR10 is tone-mapped on the card, else zscale/hable before the upload.
- 360 files: the look-around view, zoom 8°–300° (ordinary lens into stereographic, the
"little planet"), drag/pinch/wheel, phone motion. Full screen is the element's, or the
whole window on an iPhone.
- Measured (`web/scripts/e2e-player.mjs`, Chromium): an iPhone HLG clip on `core` made on
grid's A380 — 3.2 s to play, a seek 1.0 s, a quality change 1.5 s; the 8K 360 Disney day on
grid — 3.1 s, 2160s on Auto, a seek 4.9 s (10 s source keyframes).
- **Files edge sweep** — **2026-10-11** (v0.1.0-214), asked for as "a full bug sweep of - **Files edge sweep** — **2026-10-11** (v0.1.0-214), asked for as "a full bug sweep of
the Files section, every example and edge case". Every handler and both UI files read the Files section, every example and edge case". Every handler and both UI files read
through with odd inputs in mind, then a battery of ~60 requests against the live hub through with odd inputs in mind, then a battery of ~60 requests against the live hub
+445 -108
View File
@@ -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
} }
})
return t
}
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()...)
} }
switch filepath.Ext(p) { out := make([]transcodeMachine, len(names))
case ".m3u8": var wg sync.WaitGroup
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") 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
}
}
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
} }
http.ServeFile(w, r, p) r2.URL.Path = "/api/nodes/" + g.node + "/files/read"
p.ServeHTTP(w, r2)
} }
+51
View File
@@ -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)
}
}
+9 -4
View File
@@ -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))
+268 -211
View File
@@ -1,280 +1,337 @@
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
steps := flatSteps
w, h := v.Shown()
src := min(w, h) // a flat picture is named by its short side
if vr {
steps, src = vrSteps, w
}
// 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
}
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
} }
func codecKnown(id string) bool { func rung(i Info, s step) Rung {
for _, c := range Codecs { v := i.Video
if c.ID == id { sw, sh := v.Shown()
return true var w, h int
}
}
return false
}
// fit is the output: never larger than the source, even dimensions, and a bit rate by the
// size it comes to — a quarter more at 50 fps and up, less for the codecs that need less.
func fit(q Quality, src Info, codec string) (w, h, kbps int) {
w, h = src.Video.Width, src.Video.Height
if q.Height > 0 && h > q.Height {
w = int(math.Round(float64(w) * float64(q.Height) / float64(h)))
h = q.Height
}
w, h = w&^1, h&^1
switch { switch {
case h >= 2160: case v.Spherical:
kbps = 20000 w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
case h >= 1440: case sw >= sh:
kbps = 12000 h, w = s.size, even(float64(s.size)*float64(sw)/float64(sh))
case h >= 1080: default: // standing up
kbps = 8000 w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
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
}
// 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}}
} }
return []Recipe{{Engine: "Original codec", Video: v}} for _, g := range c.GPUs {
d := Device{ID: g.Device, Name: g.Name, Integrated: g.Integrated}
if d.Name == "" {
d.Name = g.Device
} }
var out []Recipe switch {
gpus := append([]GPU(nil), c.GPUs...) case g.API != "vaapi":
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated }) d.Reason = "only VAAPI devices encode here"
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"} case g.Encodes != nil && !has(g.Encodes, "h264"):
for i := range gpus { d.Reason = "doesn't encode H.264"
g := &gpus[i] default:
if g.API != "vaapi" || !has(g.Encodes, codec) { d.OK = true
continue
} }
video := append([]string{"-c:v", codec + "_vaapi"}, rate...) out = append(out, d)
switch codec {
case "h264":
video = append(video, "-profile:v", "high")
case "hevc":
video = append(video, "-tag:v", "hvc1")
} }
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
if src.HDR() {
vf = "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + vf
} }
out = append(out, Recipe{Engine: name + " (vaapi)", Pre: []string{"-hwaccel", "vaapi", "-hwaccel_device", g.Device, "-hwaccel_output_format", "vaapi"}, VF: vf, Video: video}) d := Device{ID: CPU, Name: "CPU (libx264)", OK: has(c.Encoders, "libx264")}
if !d.OK {
d.Reason = "this ffmpeg has no libx264"
} }
out = append(out, Recipe{Engine: name + " (vaapi, decoded on the CPU)", Pre: []string{"-init_hw_device", "vaapi=va:" + g.Device, "-filter_hw_device", "va"}, return append(out, d)
VF: fmt.Sprintf("format=nv12,scale=%d:%d,hwupload", w, h), Video: video}) }
// 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) + ","
} }
swVF := fmt.Sprintf("scale=%d:%d,format=yuv420p", w, h) if device != CPU {
if src.HDR() { for i := range gpus {
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) g := &gpus[i]
// Encodes is vainfo's answer; without vainfo there is none, and the card is tried
// anyway, with the next recipe behind it.
if g.API != "vaapi" || (g.Encodes != nil && !has(g.Encodes, "h264")) || (device != "" && g.Device != device) {
continue
} }
cap := []string{"-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"} video := append(append([]string{"-c:v", "h264_vaapi"}, rate...), "-bf", "0")
switch { name := g.Name
case codec == "h264" && has(c.Encoders, "libx264"): if name == "" {
out = append(out, Recipe{Engine: "CPU (libx264)", VF: swVF, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-profile:v", "high"}, cap...)}) name = g.Device
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...)}) // The card doesn't turn a phone's picture by itself (ffmpeg's own turning is for
case codec == "av1" && has(c.Encoders, "libsvtav1"): // pictures in memory), so it is told to, and ffmpeg is told not to.
out = append(out, Recipe{Engine: "CPU (SVT-AV1)", VF: swVF, Video: append([]string{"-c:v", "libsvtav1", "-preset", "8", "-crf", "35"}, cap...)}) scale := turn(src) + fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", r.Width, r.Height)
// HDR10 (PQ) is tone-mapped on the card, which needs the file's mastering data;
// HLG is made to look right on an ordinary screen as it is, so it is only scaled
// (tonemap_vaapi refuses it). Either way the next recipe tone-maps in software.
if src.Video != nil && has(g.Codecs, src.Video.Codec) {
vf := fps + scale
if src.PQ() {
vf = fps + "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + scale
}
out = append(out, Recipe{Engine: name, Device: g.Device, Pre: []string{"-noautorotate", "-hwaccel", "vaapi", "-hwaccel_device", g.Device, "-hwaccel_output_format", "vaapi"}, VF: vf, Video: video})
}
cpuVF := fmt.Sprintf("%sformat=nv12,scale=%d:%d,hwupload", fps, r.Width, r.Height)
if src.PQ() {
cpuVF = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=nv12,hwupload", r.Width, r.Height)
}
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: cpuVF, Video: video})
}
}
if has(c.Encoders, "libx264") {
vf := fmt.Sprintf("%sscale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
if src.PQ() {
vf = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=yuv420p", r.Width, r.Height)
}
out = append(out, Recipe{Engine: "CPU (libx264)", Device: CPU, VF: vf, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-sc_threshold", "0"}, rate...)})
} }
return out return out
} }
// EngineFor is the engine the picker will say a codec runs on, "" when none can. // turn is the card's filter that turns a picture the way it is meant to be shown.
func EngineFor(c Caps, codec string) string { func turn(src Info) string {
if c.Error != "" { if src.Video == nil {
return "" return ""
} }
if codec == "copy" { switch src.Video.Rotation {
return "Original codec" case 90:
return "transpose_vaapi=dir=clock,"
case 180:
return "transpose_vaapi=dir=reversal,"
case 270:
return "transpose_vaapi=dir=cclock,"
} }
r := recipes(c, codec, Info{Video: &Track{Codec: "h264", Width: 1920, Height: 1080}}, 1920, 1080, 8000)
if len(r) == 0 {
return "" return ""
}
return r[0].Engine
} }
// hlsArgs is the whole ffmpeg argument list for one recipe: the input, the first video and // toneMap turns an HDR10 picture into an ordinary one, in software.
// audio streams, the picture through the recipe, sound as AAC unless it already is, a const toneMap = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv"
// keyframe every segment, and an event playlist of fragmented-MP4 segments in dir.
func hlsArgs(dir, in string, src Info, r Recipe, copyCodec bool) []string {
a := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
a = append(a, r.Pre...)
a = append(a, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
if r.VF != "" {
a = append(a, "-vf", r.VF)
}
a = append(a, r.Video...)
switch {
case src.Audio == nil:
a = append(a, "-an")
case src.Audio.Codec == "aac":
a = append(a, "-c:a", "copy")
default:
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
}
if !copyCodec {
fps := src.Video.FPS
if fps <= 0 {
fps = 30
}
gop := int(math.Round(fps * hlsSeconds))
if gop < 24 {
gop = 24
}
if gop > 300 {
gop = 300
}
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSeconds))
}
a = append(a, "-f", "hls", "-hls_time", strconv.Itoa(hlsSeconds), "-hls_playlist_type", "event", "-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", "init.mp4", "-hls_segment_filename", dir+"/seg%05d.m4s", "-hls_flags", "independent_segments+temp_file",
"-progress", dir+"/progress", "-stats_period", "1", dir+"/index.m3u8")
return a
}
const hlsSeconds = 4 // Seg is the length of a piece, in seconds.
const Seg = 3.0
// tsBase is added to every run's timestamps. A run from the top would otherwise start just // tsBase is added to every run's times, so pieces from different runs follow on: without it
// below zero (the B-frame delay) and the muxer would shift it to make room, while a run from a // a run from the top would start just below zero (the B-frame and AAC delay) and the muxer
// seek point would not: with the base, no run is ever shifted, and a segment means the same // would shift that run alone.
// 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 {
+106 -106
View File
@@ -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,96 @@ 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[1].VF, "tonemap=hable") || !strings.HasSuffix(rs[1].VF, "hwupload") || !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 … %q", rs[0].VF, rs[1].VF, rs[len(rs)-1].VF)
}
hdr.Video.ColorTransfer = "arib-std-b67" // HLG: shown as it is, on every path
if rs = recipes(c, "", hdr, r720); strings.Contains(rs[0].VF, "tonemap") || strings.Contains(rs[1].VF, "tonemap") {
t.Fatalf("hlg: %q … %q", rs[0].VF, rs[1].VF)
}
// a phone's picture, upright: the ladder by the shown size, turned on the card
phone := src("hevc", 1920, 1080, 30)
phone.Video.Rotation = 90
lr := Ladder(phone)
if lr[0].Width != 1080 || lr[0].Height != 1920 {
t.Fatalf("upright: %+v", lr[0])
}
if rs = recipes(c, "", phone, lr[0]); !strings.HasPrefix(rs[0].VF, "transpose_vaapi=dir=clock,scale_vaapi=w=1080:h=1920") || rs[0].Pre[0] != "-noautorotate" || strings.Contains(rs[1].VF, "transpose") {
t.Fatalf("turned: %q %v / %q", rs[0].VF, rs[0].Pre, rs[1].VF)
}
// no vainfo: the card is tried all the same
nov := gridCaps()
nov.GPUs[1].Encodes = nil
if rs = recipes(nov, "/dev/dri/renderD129", in, r1080); rs[0].Engine != "Intel Arc A380" || !Devices(nov)[1].OK {
t.Fatalf("no vainfo: %v", engineNames(rs))
}
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
View File
@@ -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")
}
-669
View File
@@ -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)
}
-220
View File
@@ -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]
}
+36 -2
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"math"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -25,6 +26,16 @@ 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
Rotation int `json:"rotation,omitempty"` // degrees clockwise to turn it to be shown: 0, 90, 180, 270 (a phone held upright)
}
// Shown is the picture's size as it is shown, turned by its rotation.
func (t *Track) Shown() (w, h int) {
if t.Rotation == 90 || t.Rotation == 270 {
return t.Height, t.Width
}
return t.Width, t.Height
} }
// 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.
@@ -37,7 +48,12 @@ type Info struct {
Audio *Track `json:"audio,omitempty"` // the first sound stream Audio *Track `json:"audio,omitempty"` // the first sound stream
} }
// HDR reports a picture that needs tone mapping to look right on an SDR screen. // PQ reports HDR10 (and Dolby Vision's base layer): a picture that can't be shown on an SDR
// screen without tone mapping. HLG, the other kind, can, if a little flat.
func (i Info) PQ() bool { return i.Video != nil && i.Video.ColorTransfer == "smpte2084" }
// HDR reports a picture that needs tone mapping to look right on an SDR screen. (The player
// tone-maps only PQ: HLG is made to be shown on an SDR screen as it is.)
func (i Info) HDR() bool { func (i Info) HDR() bool {
return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67") return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67")
} }
@@ -58,6 +74,11 @@ 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"`
Rotation float64 `json:"rotation"`
} `json:"side_data_list"`
} `json:"streams"` } `json:"streams"`
Format struct { Format struct {
FormatName string `json:"format_name"` FormatName string `json:"format_name"`
@@ -74,7 +95,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,rotation: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 +126,19 @@ 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
}
if sd.Type == "Display Matrix" {
// ffprobe's rotation is counter-clockwise; ffmpeg turns it back the other way
st.Rotation = ((-int(math.Round(sd.Rotation/90))*90)%360 + 360) % 360
}
}
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)
+462
View File
@@ -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
}
}
}
+129
View File
@@ -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)
}
}
+6
View File
@@ -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
+10
View File
@@ -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.
+107
View File
@@ -0,0 +1,107 @@
// The Files player, end to end in Chromium against a running hub. Playwright is borrowed from
// the wedding site.
//
// BASE=http://10.0.0.234:8100 USER=… PASS=… MACHINE=hub FILE=/path/to/video node scripts/e2e-player.mjs
//
// Opens FILE in the Files viewer on MACHINE ("hub" for the hub itself), plays it on Auto,
// checks the 360 view if it is one, seeks into the middle, picks the lowest quality, tries
// Original, and reads the menu's "Made on …". Changes nothing. DEVICE="Pixel 7" for a phone;
// TRACE=1 prints every piece asked for. Screenshots land in $SHOTS (default /tmp/hamlab-shots).
import { createRequire } from 'node:module'
import { mkdirSync } from 'node:fs'
const require = createRequire(import.meta.url)
const { chromium, devices } = require('/home/core/projects/wedding-website/node_modules/playwright')
const { BASE = 'http://10.0.0.234:8100', USER, PASS, MACHINE = 'hub', FILE, DEVICE, TRACE } = process.env
const SHOTS = process.env.SHOTS ?? '/tmp/hamlab-shots'
if (!USER || !PASS || !FILE) { console.error('USER, PASS and FILE are required'); process.exit(2) }
mkdirSync(SHOTS, { recursive: true })
let failed = 0
const check = (name, ok, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); if (!ok) failed++ }
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
const ctx = await browser.newContext({ ...(DEVICE ? devices[DEVICE] : { viewport: { width: 1280, height: 860 } }), colorScheme: 'dark' })
check('signs in', (await ctx.request.post(BASE + '/api/login', { data: { username: USER, password: PASS } })).ok())
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(String(e)))
page.on('console', (m) => { if (m.type() === 'error' && !/401|404|favicon/.test(m.text())) errors.push(m.text()) })
const T0 = Date.now(), trace = []
const short = (u) => u.replace(/.*\/files\/play\/[0-9a-f]+\//, '')
page.on('request', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} ask ${short(r.url())}`) })
page.on('requestfinished', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} got ${short(r.url())}`) })
const mark = (m) => trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} -- ${m}`)
const dir = FILE.slice(0, FILE.lastIndexOf('/')) || '/', name = FILE.slice(FILE.lastIndexOf('/') + 1)
await page.goto(`${BASE}/#/${MACHINE}/files/${encodeURIComponent(dir)}`)
await page.locator('.file-row', { hasText: name }).first().click()
await page.locator('.vp').waitFor()
const vstate = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return v ? { t: v.currentTime, paused: v.paused, w: v.videoWidth, h: v.videoHeight, rs: v.readyState, d: v.duration } : null })
async function playingPast(t, ms = 60000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) {
const s = await vstate()
if (s && s.t > t && !s.paused && s.rs >= 3) return Date.now() - t0
await page.waitForTimeout(200)
}
return -1
}
async function controls() {
if (await page.locator('.vp.bare').count()) {
const b = await page.locator('.vp').boundingBox()
if (DEVICE) await page.touchscreen.tap(b.x + 40, b.y + 40)
else await page.mouse.move(b.x + b.width / 2 + Math.random() * 40, b.y + b.height / 3)
await page.waitForTimeout(300)
}
}
let took = await playingPast(1)
check('plays on Auto', took >= 0, `${((Date.now() - T0) / 1000).toFixed(1)} s from opening`)
await page.waitForTimeout(6000)
let s = await vstate()
check('Auto settles on a quality', s?.w > 0, `${s?.w}×${s?.h}; “${await page.locator('.vp-q span').textContent()}”`)
if (await page.locator('canvas.vp-sphere').count()) {
const px = await page.evaluate(() => { const c = document.querySelector('canvas.vp-sphere'); const g = c.getContext('webgl'); const d = new Uint8Array(64); g.readPixels(c.width >> 1, c.height >> 1, 4, 4, g.RGBA, g.UNSIGNED_BYTE, d); return d.reduce((a, b) => a + b, 0) })
check('the 360° view draws', px > 0)
}
await page.screenshot({ path: `${SHOTS}/h1-auto.png` })
const mid = Math.floor(s.d / 2)
mark('seek')
await page.evaluate((m) => { document.querySelector('.vp video').currentTime = m }, mid)
took = await playingPast(mid + 0.5)
check('a seek into the middle plays again', took >= 0, `${(took / 1000).toFixed(1)} s`)
await controls()
await page.locator('.vp-q').click()
await page.waitForTimeout(2500) // the menu asks the machine what it is doing
const items = page.locator('.vp-menu button')
const n = await items.count()
check('the menu lists Auto, Original and the qualities', n >= 3, (await items.allTextContents()).join(' | '))
const foot = await page.locator('.vp-menu-f').textContent()
check('it says where it is made', /Made on \S+/.test(foot), foot)
await page.screenshot({ path: `${SHOTS}/h2-menu.png` })
const lowLabel = await items.nth(n - 1).locator('span').nth(1).textContent()
mark('pick ' + lowLabel)
const before = (await vstate()).t
const t0 = Date.now()
await items.nth(n - 1).click()
let ok = false
for (let i = 0; i < 200 && !ok; i++) { await page.waitForTimeout(200); s = await vstate(); ok = s.t > before + 1 && !s.paused && (await page.locator('.vp-q span').textContent()) === lowLabel }
check(`picking ${lowLabel} switches to it`, ok, `${s.w}×${s.h} after ${((Date.now() - t0) / 1000).toFixed(1)} s`)
await controls()
await page.locator('.vp-q').click()
await page.locator('.vp-menu button', { hasText: 'Original' }).click()
await page.waitForTimeout(4000)
const label = await page.locator('.vp-q span').textContent()
const note = await page.locator('.vp-note').count() ? await page.locator('.vp-note').textContent() : ''
check('Original plays, or falls back to Auto and says so', label === 'Original' || (/^Auto/.test(label) && /can’t play/.test(note)), `${label}${note ? ' — ' + note : ''}`)
check('still playing', (await playingPast((await vstate()).t + 0.5)) >= 0)
await page.screenshot({ path: `${SHOTS}/h3-after-original.png` })
check('no page errors', errors.length === 0, errors.slice(0, 3).join(' / '))
if (TRACE) console.log(trace.join('\n'))
await browser.close()
console.log(failed ? `${failed} failed` : 'all ok')
process.exit(failed ? 1 : 0)
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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