Compare commits
3
Commits
1464e1f7a0
...
f2997a04c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2997a04c8 | ||
|
|
fac8a3a180 | ||
|
|
d5c16540a1 |
@@ -852,6 +852,16 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
- 360 files: the look-around view, zoom 8°–300° (ordinary lens into stereographic, the
|
- 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
|
"little planet"), drag/pinch/wheel, phone motion. Full screen is the element's, or the
|
||||||
whole window on an iPhone.
|
whole window on an iPhone.
|
||||||
|
- **Video previews follow the setting too** (same day, asked for as "it should use the
|
||||||
|
same video generation device"). On the device's own machine the hub says which device
|
||||||
|
by header (`X-Hamlab-Device`); for a file elsewhere the hub asks the file's machine's
|
||||||
|
cache first (`?cached=1`), else has the device's machine make it from the hub's one-file
|
||||||
|
URL (`POST /api/files/thumb-from`) and keeps it in the file's machine's cache (`PUT
|
||||||
|
/api/files/thumb`, refused if the file changed since its listing), so each is made once.
|
||||||
|
A failure is kept as a failure. Unreachable → made where the file is, as before. Images
|
||||||
|
and audio covers stay on the file's machine's CPU (a JPEG is cheaper there than a GPU
|
||||||
|
round trip). Measured: a `hamtv` video previewed on grid's A380 in 0.22 s, then 2 ms
|
||||||
|
from hamtv's cache. The previews button's tooltip names the device.
|
||||||
- Measured (`web/scripts/e2e-player.mjs`, Chromium): an iPhone HLG clip on `core` made on
|
- 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'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).
|
grid — 3.1 s, 2160s on Auto, a seek 4.9 s (10 s source keyframes).
|
||||||
|
|||||||
+34
-1
@@ -225,6 +225,9 @@ func (s *Server) playPiece(w http.ResponseWriter, r *http.Request) {
|
|||||||
// transcodeChoice is the setting; nil is the machine the file is on.
|
// transcodeChoice is the setting; nil is the machine the file is on.
|
||||||
func (s *Server) transcodeChoice() *store.Transcode {
|
func (s *Server) transcodeChoice() *store.Transcode {
|
||||||
var t *store.Transcode
|
var t *store.Transcode
|
||||||
|
if s.store == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
s.store.View(func(st *store.State) {
|
s.store.View(func(st *store.State) {
|
||||||
if st.Transcode != nil {
|
if st.Transcode != nil {
|
||||||
c := *st.Transcode
|
c := *st.Transcode
|
||||||
@@ -242,8 +245,38 @@ type transcodeMachine struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getTranscode: GET /api/transcode — the setting, and what every machine can encode on,
|
// getTranscode: GET /api/transcode — the setting, and what every machine can encode on,
|
||||||
// asked of each now.
|
// asked of each now. ?only=selected asks only the chosen machine, for the device's name.
|
||||||
func (s *Server) getTranscode(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getTranscode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("only") == "selected" {
|
||||||
|
c := s.transcodeChoice()
|
||||||
|
out := map[string]any{"selected": c}
|
||||||
|
if c != nil {
|
||||||
|
name := c.Device
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var devs []media.Device
|
||||||
|
if c.Node == "" {
|
||||||
|
caps, _ := s.thumbs.capsFor(ctx, false, false)
|
||||||
|
devs = media.Devices(caps)
|
||||||
|
} else if code, b, err := s.nodes.Do(ctx, c.Node, "GET", "/api/files/play-devices", nil, nil); err == nil && code == 200 {
|
||||||
|
var d struct{ Devices []media.Device }
|
||||||
|
_ = json.Unmarshal(b, &d)
|
||||||
|
devs = d.Devices
|
||||||
|
}
|
||||||
|
for _, d := range devs {
|
||||||
|
if d.ID == c.Device {
|
||||||
|
name = d.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host := c.Node
|
||||||
|
if host == "" {
|
||||||
|
host = s.hostName()
|
||||||
|
}
|
||||||
|
out["host"], out["name"] = host, name
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
names := []string{""}
|
names := []string{""}
|
||||||
if s.nodes != nil {
|
if s.nodes != nil {
|
||||||
names = append(names, s.nodes.Names()...)
|
names = append(names, s.nodes.Names()...)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -49,3 +51,43 @@ func TestPlaySrc(t *testing.T) {
|
|||||||
t.Fatalf("unknown token: %d", w.Code)
|
t.Fatalf("unknown token: %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A preview made on another machine is kept by the file's own: only while the file is still
|
||||||
|
// the one it was made of, and a failure is kept as a failure.
|
||||||
|
func TestThumbPut(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)
|
||||||
|
}
|
||||||
|
fi, _ := os.Stat(p)
|
||||||
|
s := &Server{fsLocal: files.Local{}, thumbs: newThumbSvc(dir, "")}
|
||||||
|
v := fmt.Sprintf("%d-%d", fi.ModTime().UnixMilli(), fi.Size())
|
||||||
|
put := func(v string, body string, hdr map[string]string) int {
|
||||||
|
r := httptest.NewRequest("PUT", "/api/files/thumb?path="+url.QueryEscape(p)+"&v="+v, strings.NewReader(body))
|
||||||
|
for k, x := range hdr {
|
||||||
|
r.Header.Set(k, x)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.thumbPut(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
get := func() *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest("GET", "/api/files/thumb?cached=1&path="+url.QueryEscape(p)+"&v="+v, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.filesThumb(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if w := get(); w.Code != 404 {
|
||||||
|
t.Fatalf("cached=1 before anything: %d", w.Code)
|
||||||
|
}
|
||||||
|
if c := put("1-10", "jpeg", nil); c != 409 {
|
||||||
|
t.Fatalf("a changed file: %d", c)
|
||||||
|
}
|
||||||
|
if c := put(v, "jpeg-bytes", map[string]string{"X-Hamlab-Thumb": "grid vaapi:/dev/dri/renderD129"}); c != 204 {
|
||||||
|
t.Fatalf("put: %d", c)
|
||||||
|
}
|
||||||
|
if w := get(); w.Code != 200 || w.Body.String() != "jpeg-bytes" || !strings.Contains(w.Header().Get("X-Hamlab-Thumb"), "grid") {
|
||||||
|
t.Fatalf("kept: %d %q %q", w.Code, w.Body, w.Header().Get("X-Hamlab-Thumb"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,9 @@ func (s *Server) routes() *http.ServeMux {
|
|||||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||||
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
|
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
|
||||||
|
m.HandleFunc("PUT /api/files/thumb", s.auth(s.thumbPut))
|
||||||
|
m.HandleFunc("POST /api/files/thumb-from", s.auth(s.thumbFrom))
|
||||||
|
m.HandleFunc("GET /api/nodes/{name}/files/thumb", s.auth(s.hubThumb))
|
||||||
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))
|
||||||
|
|||||||
+239
-7
@@ -10,10 +10,16 @@ package api
|
|||||||
// opens every render node, whatever groups the daemon's user has.
|
// opens every render node, whatever groups the daemon's user has.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -25,6 +31,7 @@ import (
|
|||||||
|
|
||||||
"hamlab/internal/media"
|
"hamlab/internal/media"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const thumbSize = 256
|
const thumbSize = 256
|
||||||
@@ -117,7 +124,9 @@ func describeCaps(c media.Caps) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// make produces a preview, sharing the work when the same file is asked for twice at once.
|
// make produces a preview, sharing the work when the same file is asked for twice at once.
|
||||||
func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (media.Result, error) {
|
// in is a path, or a URL the hub serves another machine's file at (kind then says what it
|
||||||
|
// is); device is the one to decode on ("" any, "cpu" none).
|
||||||
|
func (t *thumbSvc) make(ctx context.Context, root bool, key, in, kind, device string) (media.Result, error) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if c, ok := t.calls[key]; ok {
|
if c, ok := t.calls[key]; ok {
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -153,19 +162,29 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (media
|
|||||||
c.err = err
|
c.err = err
|
||||||
return c.res, c.err
|
return c.res, c.err
|
||||||
}
|
}
|
||||||
o := media.Opts{Size: thumbSize, Timeout: thumbTimeout}
|
o := media.Opts{Size: thumbSize, Timeout: thumbTimeout, Kind: kind}
|
||||||
|
caps = caps.Only(device)
|
||||||
if root {
|
if root {
|
||||||
c.res, c.err = media.MakeRoot(wctx, t.self, caps, path, o)
|
c.res, c.err = media.MakeRoot(wctx, t.self, caps, in, o)
|
||||||
} else {
|
} else {
|
||||||
c.res, c.err = media.Make(wctx, caps, path, o)
|
c.res, c.err = media.Make(wctx, caps, in, o)
|
||||||
}
|
}
|
||||||
return c.res, c.err
|
return c.res, c.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- handlers ----
|
// ---- handlers ----
|
||||||
|
|
||||||
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1]. v is the client's idea of
|
// thumbDeviceHeader carries the device a hub wants a video preview made on: the Transcode
|
||||||
// the file, from its listing; it makes a cache hit free of a stat, and a changed file a new URL.
|
// device setting, when it is on the machine the file is on.
|
||||||
|
const thumbDeviceHeader = "X-Hamlab-Device"
|
||||||
|
|
||||||
|
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1][&cached=1]. v is the
|
||||||
|
// client's idea of the file, from its listing; it makes a cache hit free of a stat, and a
|
||||||
|
// changed file a new URL. cached=1 answers from the cache or 404, and makes nothing: the hub
|
||||||
|
// asks that before having the preview made on another machine.
|
||||||
|
//
|
||||||
|
// A video preview is made on the Transcode device: on the hub, by the setting (another
|
||||||
|
// machine's device reads the file through the hub); on a node, by the hub's header.
|
||||||
func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
||||||
fsys, ok := s.fsFor(w, r)
|
fsys, ok := s.fsFor(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -203,7 +222,28 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.serveCachedThumb(w, key) {
|
if s.serveCachedThumb(w, key) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.thumbs.make(r.Context(), root, key, p)
|
if r.URL.Query().Get("cached") == "1" {
|
||||||
|
jsonErr(w, 404, "not made yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device := r.Header.Get(thumbDeviceHeader)
|
||||||
|
if c := s.transcodeChoice(); c != nil && device == "" && media.Kind(p) == "video" {
|
||||||
|
if c.Node != "" {
|
||||||
|
// The hub's own file, previewed on another machine.
|
||||||
|
if res, ok := s.thumbElsewhere(r.Context(), c, "", p, root, key); ok {
|
||||||
|
if res.Failure != "" {
|
||||||
|
_ = s.thumbs.cache.Put(key, media.Entry{Path: p, Failure: res.Failure})
|
||||||
|
jsonErr(w, 422, res.Failure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.keepThumb(w, key, p, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
device = c.Device
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := s.thumbs.make(r.Context(), root, key, p, "", device)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var de *media.DecodeError
|
var de *media.DecodeError
|
||||||
switch {
|
switch {
|
||||||
@@ -223,12 +263,204 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.keepThumb(w, key, p, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepThumb puts a preview in the cache and answers with it.
|
||||||
|
func (s *Server) keepThumb(w http.ResponseWriter, key, p string, res media.Result) {
|
||||||
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil {
|
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil {
|
||||||
log.Printf("thumbs: cache: %v", err)
|
log.Printf("thumbs: cache: %v", err)
|
||||||
}
|
}
|
||||||
writeThumb(w, key, res.JPEG, res.Engine)
|
writeThumb(w, key, res.JPEG, res.Engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- previews made on another machine (the Transcode device) ----
|
||||||
|
|
||||||
|
// thumbFrom: POST /api/files/thumb-from {url, kind, key, device} — a preview of a file on
|
||||||
|
// another machine, read through the hub at url, made here on device. Answers the JPEG (the
|
||||||
|
// engine in X-Hamlab-Thumb), or 422 with why the file can't be previewed.
|
||||||
|
func (s *Server) thumbFrom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct{ URL, Kind, Key, Device string }
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 8192)).Decode(&body); err != nil || !strings.HasPrefix(body.URL, "http") || body.Key == "" {
|
||||||
|
jsonErr(w, 400, "expected {url, kind, key, device}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.thumbs.make(r.Context(), false, "from:"+body.Key, body.URL, body.Kind, body.Device)
|
||||||
|
if err != nil {
|
||||||
|
var de *media.DecodeError
|
||||||
|
if errors.As(err, &de) {
|
||||||
|
jsonErr(w, 422, de.Msg)
|
||||||
|
} else {
|
||||||
|
jsonErr(w, 503, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Hamlab-Thumb-Codec", res.Codec)
|
||||||
|
w.Header().Set("X-Hamlab-Thumb-Size", fmt.Sprintf("%dx%d", res.Width, res.Height))
|
||||||
|
writeThumb(w, body.Key, res.JPEG, res.Engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// thumbPut: PUT /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1] — a preview another machine
|
||||||
|
// made of this file, kept here as if made here. The body is the JPEG, or nothing with
|
||||||
|
// X-Hamlab-Thumb-Failure saying why the file can't be previewed. Refused if the file has
|
||||||
|
// changed since v.
|
||||||
|
func (s *Server) thumbPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fsys, ok := s.fsFor(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, ok := cleanQuery(w, r, "path")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mt, sz, ok := parseV(r.URL.Query().Get("v"))
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 400, "v=<mtime>-<size> is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := fsys.Stat(r.Context(), p)
|
||||||
|
if err != nil {
|
||||||
|
fsErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Size != sz || in.Mtime != mt {
|
||||||
|
jsonErr(w, 409, "the file has changed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := media.Key(p, sz, mt)
|
||||||
|
e := media.Entry{Path: p, Engine: r.Header.Get("X-Hamlab-Thumb"), Codec: r.Header.Get("X-Hamlab-Thumb-Codec"), Failure: r.Header.Get("X-Hamlab-Thumb-Failure")}
|
||||||
|
fmt.Sscanf(r.Header.Get("X-Hamlab-Thumb-Size"), "%dx%d", &e.Width, &e.Height)
|
||||||
|
if e.Failure == "" {
|
||||||
|
if e.JPEG, err = io.ReadAll(io.LimitReader(r.Body, 4<<20)); err != nil || len(e.JPEG) == 0 {
|
||||||
|
jsonErr(w, 400, "no picture")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.thumbs.cache.Put(key, e); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// thumbElsewhere has a video preview made on the Transcode device's machine c.Node, of a
|
||||||
|
// file on machine node ("" the hub), and answers it. ok is false when that machine can't
|
||||||
|
// (unreachable, an older Ham Lab): the caller makes it where the file is instead. A file
|
||||||
|
// that can't be previewed is a result too, with the failure in it.
|
||||||
|
func (s *Server) thumbElsewhere(ctx context.Context, c *store.Transcode, node, p string, root bool, key string) (media.Result, bool) {
|
||||||
|
src, err := s.srcURL(c.Node, node, p, root)
|
||||||
|
if err != nil {
|
||||||
|
return media.Result{}, false
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]string{"url": src, "kind": "video", "key": key, "device": c.Device})
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, thumbTimeout+10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var code int
|
||||||
|
var b []byte
|
||||||
|
var hdr http.Header
|
||||||
|
if c.Node == "" {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/api/files/thumb-from", bytes.NewReader(body)).WithContext(ctx)
|
||||||
|
s.thumbFrom(rec, req)
|
||||||
|
code, b, hdr = rec.Code, rec.Body.Bytes(), rec.Header()
|
||||||
|
} else {
|
||||||
|
code, b, hdr, err = s.nodes.DoHeaders(ctx, c.Node, "POST", "/api/files/thumb-from", body, http.Header{"Content-Type": []string{"application/json"}})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("thumbs: %s on %s: %v; made where it is", p, c.Node, err)
|
||||||
|
return media.Result{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case 200:
|
||||||
|
res := media.Result{JPEG: b, Engine: c.Node + " " + hdr.Get("X-Hamlab-Thumb"), Codec: hdr.Get("X-Hamlab-Thumb-Codec")}
|
||||||
|
fmt.Sscanf(hdr.Get("X-Hamlab-Thumb-Size"), "%dx%d", &res.Width, &res.Height)
|
||||||
|
if c.Node == "" {
|
||||||
|
res.Engine = s.hostName() + " " + hdr.Get("X-Hamlab-Thumb")
|
||||||
|
}
|
||||||
|
return res, true
|
||||||
|
case 422:
|
||||||
|
var e struct{ Error string }
|
||||||
|
_ = json.Unmarshal(b, &e)
|
||||||
|
return media.Result{Failure: e.Error}, true
|
||||||
|
}
|
||||||
|
log.Printf("thumbs: %s on %s answered %d; made where it is", p, nodeLabel(c.Node), code)
|
||||||
|
return media.Result{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubThumb: GET /api/nodes/{name}/files/thumb — a node's preview, through the hub. Without a
|
||||||
|
// Transcode device setting, or for an image or a sound file's cover, it goes to the node as
|
||||||
|
// any request does. A video is made on the setting's device: the node's own (told by header),
|
||||||
|
// or another machine's, which reads the file through the hub; the node keeps the result in
|
||||||
|
// its own cache either way.
|
||||||
|
func (s *Server) hubThumb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node := r.PathValue("name")
|
||||||
|
proxy, ok := s.nodes.Proxy(node)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := s.transcodeChoice()
|
||||||
|
q := r.URL.Query()
|
||||||
|
p := q.Get("path")
|
||||||
|
if c == nil || media.Kind(p) != "video" {
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Node == node {
|
||||||
|
r.Header.Set(thumbDeviceHeader, c.Device)
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mt, sz, okV := parseV(q.Get("v"))
|
||||||
|
if !okV {
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The node's own cache first.
|
||||||
|
cq := url.Values{"path": {p}, "v": {q.Get("v")}, "cached": {"1"}}
|
||||||
|
if q.Get("sudo") == "1" {
|
||||||
|
cq.Set("sudo", "1")
|
||||||
|
}
|
||||||
|
hdr := http.Header{}
|
||||||
|
if inm := r.Header.Get("If-None-Match"); inm != "" {
|
||||||
|
hdr.Set("If-None-Match", inm)
|
||||||
|
}
|
||||||
|
code, b, rh, err := s.nodes.DoHeaders(r.Context(), node, "GET", "/api/files/thumb?"+cq.Encode(), nil, hdr)
|
||||||
|
if err == nil && code != 404 {
|
||||||
|
for _, k := range []string{"Content-Type", "Cache-Control", "ETag", "X-Hamlab-Thumb"} {
|
||||||
|
if v := rh.Get(k); v != "" {
|
||||||
|
w.Header().Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := media.Key(p, sz, mt)
|
||||||
|
res, made := s.thumbElsewhere(r.Context(), c, node, p, q.Get("sudo") == "1", key)
|
||||||
|
if !made {
|
||||||
|
proxy.ServeHTTP(w, r) // the node makes it itself
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Kept where the file is, so it is made once.
|
||||||
|
ph := http.Header{"X-Hamlab-Thumb": {res.Engine}, "X-Hamlab-Thumb-Codec": {res.Codec}, "X-Hamlab-Thumb-Size": {fmt.Sprintf("%dx%d", res.Width, res.Height)}}
|
||||||
|
if res.Failure != "" {
|
||||||
|
ph.Set("X-Hamlab-Thumb-Failure", res.Failure)
|
||||||
|
}
|
||||||
|
pq := url.Values{"path": {p}, "v": {q.Get("v")}}
|
||||||
|
if q.Get("sudo") == "1" {
|
||||||
|
pq.Set("sudo", "1")
|
||||||
|
}
|
||||||
|
if code, _, _, err := s.nodes.DoHeaders(r.Context(), node, "PUT", "/api/files/thumb?"+pq.Encode(), res.JPEG, ph); err != nil || code != 204 {
|
||||||
|
log.Printf("thumbs: keeping %s's preview of %s: %d %v", node, p, code, err)
|
||||||
|
}
|
||||||
|
if res.Failure != "" {
|
||||||
|
jsonErr(w, 422, res.Failure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeThumb(w, key, res.JPEG, res.Engine)
|
||||||
|
}
|
||||||
|
|
||||||
// serveCachedThumb answers from the cache when it can. A cache that cannot be read is a
|
// serveCachedThumb answers from the cache when it can. A cache that cannot be read is a
|
||||||
// miss: the preview is made again rather than refused.
|
// miss: the preview is made again rather than refused.
|
||||||
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
|
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
|
||||||
|
|||||||
@@ -155,3 +155,17 @@ func TestPieceArgs(t *testing.T) {
|
|||||||
t.Error("no sound means -an")
|
t.Error("no sound means -an")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCapsOnly(t *testing.T) {
|
||||||
|
c := gridCaps()
|
||||||
|
if got := c.Only("/dev/dri/renderD129"); len(got.GPUs) != 1 || got.GPUs[0].Name != "Intel Arc A380" || len(c.GPUs) != 2 {
|
||||||
|
t.Fatalf("one card: %+v", got.GPUs)
|
||||||
|
}
|
||||||
|
if len(c.Only(CPU).GPUs) != 0 || len(c.Only("/dev/dri/renderD200").GPUs) != 0 || len(c.Only("").GPUs) != 2 {
|
||||||
|
t.Fatal("cpu, a device not here, any")
|
||||||
|
}
|
||||||
|
// previews then go to that card, or the CPU
|
||||||
|
if g := c.Only("/dev/dri/renderD129").pick("hevc"); g == nil || g.Device != "/dev/dri/renderD129" {
|
||||||
|
t.Fatalf("pick: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ type Caps struct {
|
|||||||
type Opts struct {
|
type Opts struct {
|
||||||
Size int // the long edge in pixels; 256 when zero
|
Size int // the long edge in pixels; 256 when zero
|
||||||
Timeout time.Duration // for the whole job, ffprobe and every ffmpeg attempt; 30 s when zero
|
Timeout time.Duration // for the whole job, ffprobe and every ffmpeg attempt; 30 s when zero
|
||||||
|
Kind string // image | video | audio; from the name when empty (a URL has none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only is the caps with one device left to decode on: a render node, CPU for none, or ""
|
||||||
|
// for all of them — the Transcode device setting, which previews follow too. A device this
|
||||||
|
// machine doesn't have leaves the CPU.
|
||||||
|
func (c Caps) Only(device string) Caps {
|
||||||
|
if device == "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
out := c
|
||||||
|
out.GPUs = nil
|
||||||
|
for _, g := range c.GPUs {
|
||||||
|
if g.Device == device {
|
||||||
|
out.GPUs = append(out.GPUs, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o Opts) defaults() Opts {
|
func (o Opts) defaults() Opts {
|
||||||
@@ -81,6 +99,8 @@ type Result struct {
|
|||||||
Codec string `json:"codec"`
|
Codec string `json:"codec"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
|
// Failure is why the file can't be previewed, when another machine tried (no JPEG then).
|
||||||
|
Failure string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrUnsupported: not an image or a video by its name.
|
// ErrUnsupported: not an image or a video by its name.
|
||||||
@@ -356,7 +376,10 @@ func (a attempt) engine() string {
|
|||||||
|
|
||||||
// Make produces the preview of one file.
|
// Make produces the preview of one file.
|
||||||
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) {
|
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) {
|
||||||
kind := Kind(path)
|
kind := o.Kind
|
||||||
|
if kind == "" {
|
||||||
|
kind = Kind(path)
|
||||||
|
}
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
return Result{}, ErrUnsupported
|
return Result{}, ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -383,13 +383,19 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bo
|
|||||||
// WebSocket origin check (meant for browsers) doesn't reject the hub.
|
// WebSocket origin check (meant for browsers) doesn't reject the hub.
|
||||||
// Do makes one request to a managed machine as this hub and returns the whole response.
|
// Do makes one request to a managed machine as this hub and returns the whole response.
|
||||||
func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, error) {
|
func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, error) {
|
||||||
|
code, b, _, err := m.DoHeaders(ctx, name, method, path, body, hdr)
|
||||||
|
return code, b, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoHeaders is Do that also answers the response's headers.
|
||||||
|
func (m *Manager) DoHeaders(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, http.Header, error) {
|
||||||
n, ok := m.node(name)
|
n, ok := m.node(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, nil, errors.New("no such machine")
|
return 0, nil, nil, errors.New("no such machine")
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, nil, err
|
||||||
}
|
}
|
||||||
for k, v := range hdr {
|
for k, v := range hdr {
|
||||||
req.Header[k] = v
|
req.Header[k] = v
|
||||||
@@ -397,11 +403,11 @@ func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte
|
|||||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||||
resp, err := m.client(n).Do(req)
|
resp, err := m.client(n).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, errors.New(shortErr(err))
|
return 0, nil, nil, errors.New(shortErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
return resp.StatusCode, b, err
|
return resp.StatusCode, b, resp.Header, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL is a managed machine's address, "" for one not managed here.
|
// URL is a managed machine's address, "" for one not managed here.
|
||||||
|
|||||||
+4
-1
@@ -307,6 +307,9 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
const [thumbs, setThumbs] = useState<ThumbStatus | null>(null)
|
const [thumbs, setThumbs] = useState<ThumbStatus | null>(null)
|
||||||
const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
|
const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
|
||||||
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews])
|
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews])
|
||||||
|
// Video previews are made on the Transcode device (Settings), wherever it is.
|
||||||
|
const [tc, setTc] = useState<string>('')
|
||||||
|
useEffect(() => { api.transcodeSelected().then((t) => setTc(t.selected ? `videos on ${t.host}'s ${t.name} (Settings → Transcode device), images on this machine's CPU` : '')).catch(() => {}) }, [])
|
||||||
const readThumbs = useCallback((refresh = false) => { api.thumbStatus(sudo, refresh).then(setThumbs).catch((e) => setThumbs({ ok: false, engine: '', summary: '', gpus: [], error: e instanceof Error ? e.message : 'failed', user: '', cache: { path: '', entries: 0, bytes: 0 } })) }, [sudo])
|
const readThumbs = useCallback((refresh = false) => { api.thumbStatus(sudo, refresh).then(setThumbs).catch((e) => setThumbs({ ok: false, engine: '', summary: '', gpus: [], error: e instanceof Error ? e.message : 'failed', user: '', cache: { path: '', entries: 0, bytes: 0 } })) }, [sudo])
|
||||||
useEffect(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
|
useEffect(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
|
||||||
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
|
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
|
||||||
@@ -409,7 +412,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
<div className="files-tools">
|
<div className="files-tools">
|
||||||
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
|
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
|
||||||
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
|
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
|
||||||
<button className={'btn' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</button>
|
<button className={'btn' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${tc || thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</button>
|
||||||
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
|
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
|
||||||
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
<button className="btn" onClick={mkdir}>+ folder</button>
|
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||||
|
|||||||
@@ -298,7 +298,8 @@ function TranscodeDevice() {
|
|||||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||||
<div className="muted small" style={{ marginTop: 10 }}>
|
<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,
|
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
|
or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. Video previews
|
||||||
|
(thumbnails) are made on it too; image previews stay on each machine's CPU. 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.
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export const api = {
|
|||||||
playStatus: (node: string, id: string) => req<PlayStatus>('GET', nodeAPI(node, `/api/files/play/${id}`), undefined, 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),
|
playStop: (node: string, id: string) => req<void>('DELETE', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||||
transcode: () => req<TranscodeSetting>('GET', '/api/transcode', undefined, true),
|
transcode: () => req<TranscodeSetting>('GET', '/api/transcode', undefined, true),
|
||||||
|
transcodeSelected: () => req<{ selected: { node: string; device: string } | null; host?: string; name?: string }>('GET', '/api/transcode?only=selected', undefined, true),
|
||||||
setTranscode: (node: string, device: string) => req<TranscodeSetting>('PUT', '/api/transcode', device ? { node, device } : {}, 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)}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user