Files: re-encode a video the browser can't play, on the machine's own GPU, and play that
The viewer tries the file as it is first. When the browser gives up — Safari and an MKV, HEVC without a hardware decoder — or on a tap, a picker: codec (H.264, HEVC, AV1, or the original codec repackaged) and size (original, 1080p, 720p, 480p), each codec labelled with the engine this machine would use. POST /api/files/play runs ffmpeg into an HLS event playlist of fragmented-MP4 segments under <data dir>/transcode/<id>/; Safari plays HLS itself and everything else goes through hls.js, loaded only then. The recipes are tried best first: a card on the bus before the integrated GPU, decoding on the same card before decoding on the CPU and uploading, the software encoders last; a recipe that produces no first segment is dropped for the next. The seekable range grows as ffmpeg gets ahead; status says how far. Streams are reused for the same request, closed when the player goes, swept after ten idle minutes, and the folder is emptied at start. Root mode runs ffmpeg as root through a helper that ends when the daemon lets go of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
aa4ae57c22
commit
f3c547adfa
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -1426,6 +1427,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
||||
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
+5
-6
@@ -4,6 +4,7 @@ import { fmtBytes } from './fmt'
|
||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||
import { useHashSel } from './hashsel'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import Player from './Player'
|
||||
|
||||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||
@@ -253,15 +254,13 @@ 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)} />
|
||||
)}
|
||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||
{/* Video and audio go to the browser as they are, codec and all, and it plays what it can. */}
|
||||
{info && text === null && !isImage(name) && info.preview === 'video' && !cantPlay && (
|
||||
<div className="preview"><video controls playsInline preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div>
|
||||
)}
|
||||
{/* Video: the browser's own decoder first, and a re-encode on the machine when that fails. 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 === '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>
|
||||
)}
|
||||
{info && text === null && !isImage(name) && cantPlay && (
|
||||
<div className="notice">This browser can't play {name} as it is — the container or codec isn't one it decodes. Download it instead.</div>
|
||||
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
||||
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
||||
)}
|
||||
{info && text === null && !isImage(name) && !info.preview && !err && (
|
||||
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// A video in the Files viewer. First the file as it is, handed to the browser's own
|
||||
// decoder; when that fails — Safari and an MKV, HEVC without a hardware decoder — a
|
||||
// picker: codec and size, and the machine re-encodes it to HLS on its own GPU (or CPU),
|
||||
// which plays natively on Safari and through hls.js everywhere else. The same picker is
|
||||
// one tap away even when the file plays, for a 36 Mbps capture over a slow link.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, apiPath, PlayCaps, PlayStatus, PlayStream } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
|
||||
type Mode = 'native' | 'pick' | 'hls'
|
||||
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 fmtMbps = (bps?: number) => (bps ? `${(bps / 1e6).toFixed(bps >= 10e6 ? 0 : 1)} Mbps` : '')
|
||||
const describe = (s: PlayStream) => {
|
||||
const v = s.source.video
|
||||
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 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}`
|
||||
}
|
||||
|
||||
export default function Player({ path, sudo, name }: { path: string; sudo: boolean; name: string }) {
|
||||
const [mode, setMode] = useState<Mode>('native')
|
||||
const [why, setWhy] = useState('') // what brought the picker up
|
||||
const [caps, setCaps] = useState<PlayCaps | null>(null)
|
||||
const [codec, setCodec] = useState(() => recall(CODEC_KEY, 'h264'))
|
||||
const [quality, setQuality] = useState(() => recall(QUALITY_KEY, '1080p'))
|
||||
const [stream, setStream] = useState<{ s: PlayStream; url: string } | null>(null)
|
||||
const [status, setStatus] = useState<PlayStatus | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
const streamRef = useRef(stream)
|
||||
streamRef.current = stream
|
||||
|
||||
useEffect(() => { setMode('native'); setWhy(''); setStream(null); setStatus(null); setErr('') }, [path, sudo])
|
||||
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
|
||||
const stop = useCallback(() => { const s = streamRef.current; if (s) { api.playStop(s.s.id).catch(() => { /* gone already */ }); setStream(null); setStatus(null) } }, [])
|
||||
useEffect(() => () => stop(), [stop])
|
||||
|
||||
const start = async () => {
|
||||
setErr('')
|
||||
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(() => {
|
||||
const v = video.current
|
||||
if (mode !== 'hls' || !stream || !v) return
|
||||
const url = apiPath(stream.url)
|
||||
let hls: { destroy: () => void } | null = null
|
||||
let gone = false
|
||||
;(async () => {
|
||||
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
v.src = url
|
||||
} else {
|
||||
const { default: Hls } = await import('hls.js')
|
||||
if (gone) return
|
||||
if (!Hls.isSupported()) { setErr('this browser has neither HLS nor MediaSource — nothing can play here'); return }
|
||||
const h = new Hls({ maxBufferLength: 60, backBufferLength: 30 })
|
||||
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 */ })
|
||||
})()
|
||||
return () => { gone = true; hls?.destroy() }
|
||||
}, [mode, stream])
|
||||
|
||||
// follow the re-encode while it runs
|
||||
useEffect(() => {
|
||||
if (mode !== 'hls' || !stream) return
|
||||
let stopPolling = false
|
||||
const tick = async () => { try { const st = await api.playStatus(stream.s.id); if (!stopPolling) { setStatus(st); if (st.running) setTimeout(tick, 2000) } } catch { /* ended */ } }
|
||||
tick()
|
||||
return () => { stopPolling = true }
|
||||
}, [mode, stream])
|
||||
|
||||
const pick = (reason: string) => { setWhy(reason); setMode('pick') }
|
||||
const codecs = caps?.codecs ?? []
|
||||
|
||||
return (
|
||||
<div className="player">
|
||||
{mode === 'native' && (
|
||||
<>
|
||||
<div className="play-bar"><span>as it is</span><span className="spacer" /><button className="btn" onClick={() => pick('')}>Re-encode…</button></div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{mode === 'pick' && (
|
||||
<div className="play-pick notice">
|
||||
<div>{why || `Re-encode ${name} on this machine and play that instead.`}</div>
|
||||
{caps?.error && <div className="error" style={{ marginTop: 8 }}>{caps.error}</div>}
|
||||
{!caps && !err && <div className="muted small" style={{ marginTop: 8 }}><Spinner /> asking the machine what it can encode…</div>}
|
||||
{caps && !caps.error && (
|
||||
<div className="row">
|
||||
<label>codec
|
||||
<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>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>size
|
||||
<select value={quality} onChange={(e) => setQuality(e.target.value)} disabled={codec === 'copy'}>
|
||||
{caps.qualities.map((q) => <option key={q.id} value={q.id}>{q.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Btn className="btn on" disabled={!codecs.find((c) => c.id === codec)?.engine} onClick={start}>Play</Btn>
|
||||
<a className="btn" href={api.fileDownloadURL(path, sudo)} download={name}>Download instead</a>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'hls' && stream && (
|
||||
<>
|
||||
<div className="play-bar">
|
||||
<span title={describe(stream.s)}>{describe(stream.s)}</span>
|
||||
{status && status.running && <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" />
|
||||
<button className="btn" onClick={() => { stop(); setMode('pick'); setWhy('') }}>Change…</button>
|
||||
</div>
|
||||
<div className="preview"><video ref={video} controls autoPlay playsInline /></div>
|
||||
{err && <div className="error" style={{ margin: 12 }}>{err}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ export interface ARCDetail {
|
||||
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 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 }
|
||||
export interface PlayStream { id: string; codec: string; quality: string; engine: string; width: number; height: number; kbps: number; source: { container: string; duration: number; bit_rate?: number; video?: PlayTrack; audio?: PlayTrack } }
|
||||
export interface PlayStatus { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number }
|
||||
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.
|
||||
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 } }
|
||||
@@ -300,6 +304,11 @@ export const api = {
|
||||
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'),
|
||||
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) => {
|
||||
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()
|
||||
|
||||
@@ -365,6 +365,13 @@ 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 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; }
|
||||
.player { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.player .preview { flex: 1; min-height: 0; }
|
||||
.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); }
|
||||
.play-bar select { padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12px; }
|
||||
.play-pick { margin: 12px; }
|
||||
.play-pick .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.play-pick label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.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 .ficon { font-size: 48px; }
|
||||
|
||||
Reference in New Issue
Block a user