Files: previews — a toggle that shows a thumbnail per image and video, made by ffmpeg on the machine's GPU where it has one, cached under the data dir

A previews button on the Files tab turns the icon column into a 256 px thumbnail for
every image and video row, loaded as rows scroll near. internal/thumbs probes what the
machine can do rather than assuming it: ffmpeg and ffprobe, every /dev/dri/renderD*
opened and exercised, vainfo's decode profiles per node where it exists. A video goes to
the first GPU that lists its codec, with ffmpeg's own software fallback behind it and a
CPU retry behind that; images decode on the CPU, JPEGs at a reduced DCT size. Previews
are kept under <data dir>/thumbs/ keyed by path, size and mtime, with undecodable files
remembered too. Root mode makes them through the same root helper the file reads use.
Where ffmpeg is missing the tab offers to install it; where a render node exists but is
closed to the daemon's user, it offers a drop-in adding the group and a restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 15:20:27 -07:00
co-authored by Claude Fable 5.1
parent e7034cd478
commit f8c9479e93
13 changed files with 1483 additions and 16 deletions
+86 -5
View File
@@ -1,8 +1,9 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api'
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, FileEntry, FileInfo, ThumbStatus } from './api'
import { fmtBytes } from './fmt'
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
import { useHashSel } from './hashsel'
import TaskOutput from './TaskOutput'
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' }) }
@@ -14,7 +15,8 @@ function icon(e: FileEntry): string {
if (e.type === 'dir') return e.target ? '🔗' : '📁'
if (e.type === 'link') return e.broken ? '⛓️‍💥' : '🔗'
if (e.type === 'other') return '⚙️'
if (isImage(e.name)) return '🖼️'
if (e.preview === 'image' || isImage(e.name)) return '🖼️'
if (e.preview === 'video') return '🎞️'
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
@@ -22,6 +24,74 @@ function icon(e: FileEntry): string {
}
type Sort = 'name' | 'size' | 'mtime'
const PREVIEWS_KEY = 'hamlab.files.previews'
// ---- previews ----
// Rows ask for their thumbnail only as they scroll near: a browser's own lazy loading
// watches the window, and this list scrolls inside a box of its own. One observer for the
// list, a callback per row, each fired once.
class Near {
private obs: IntersectionObserver
private cbs = new Map<Element, () => void>()
constructor(root: Element) {
this.obs = new IntersectionObserver((ents) => {
for (const e of ents) if (e.isIntersecting) { const cb = this.cbs.get(e.target); if (cb) { this.forget(e.target); cb() } }
}, { root, rootMargin: '400px 0px' })
}
watch(el: Element, cb: () => void) { this.cbs.set(el, cb); this.obs.observe(el); return () => this.forget(el) }
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
}
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video'; near: Near | null; fallback: string }) {
const ref = useRef<HTMLSpanElement>(null)
const [show, setShow] = useState(false)
const [failed, setFailed] = useState('')
useEffect(() => { const el = ref.current; if (!el || !near || show) return; return near.watch(el, () => setShow(true)) }, [near, show])
useEffect(() => { setFailed('') }, [url]) // a changed file is a new URL, and worth another try
const broken = failed === url
return (
<span ref={ref} className={'fthumb ' + kind} title={broken ? 'no preview for this file' : undefined}>
{show && !broken && <img src={url} alt="" decoding="async" onError={() => setFailed(url)} />}
{(!show || broken) && <span className="ficon">{fallback}</span>}
</span>
)
}
// What the previews button says about how they get made here, and the one-tap fixes.
function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: boolean; onChanged: () => void }) {
const [task, setTask] = useState<{ id: string; restart: boolean } | null>(null)
const [waiting, setWaiting] = useState(false)
// the GPU fix ends by restarting Ham Lab: poll until it answers again, then re-read the status
useEffect(() => {
if (!waiting) return
const t = setInterval(async () => { try { await api.thumbStatus(sudo, true); setWaiting(false); onChanged() } catch { /* restarting */ } }, 3000)
return () => clearInterval(t)
}, [waiting, sudo, onChanged])
if (!st) return null
const blocked = (st.unusable ?? []).filter((g) => g.permission)
const start = async (label: string, fn: () => Promise<{ id: string }>, restart: boolean) => {
try { const t = await fn(); setTask({ id: t.id, restart }) } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
return (
<>
{!st.ok && (
<div className="thumb-note notice">
<span>Previews need ffmpeg: {st.error || 'it is not installed on this machine'}.</span>
{st.install && !task && <Btn className="btn on" onClick={() => start('install', api.thumbInstall, false)}>Install ffmpeg with {st.install.pm}</Btn>}
</div>
)}
{st.ok && blocked.length > 0 && (
<div className="thumb-note notice">
<span>{blocked.map((g) => g.name || g.device).join(', ')} is here, but Ham Lab runs as {st.user} and can't open {blocked.length > 1 ? 'them' : 'it'} ({blocked[0].reason}). Previews of videos are on the CPU until it can.</span>
{st.gpu_fix && !task && !waiting && <Btn className="btn on" confirm="Ham Lab adds itself to the GPU's group and restarts. Open terminals are interrupted. Continue?" onClick={() => start('GPU access', api.thumbGPUAccess, true)}>Allow GPU access</Btn>}
{waiting && <span className="muted small"><Spinner /> Ham Lab is restarting; the page reconnects by itself.</span>}
</div>
)}
{task && <TaskOutput id={task.id} onDone={(t) => { const ok = t.exit_code === 0 && !t.error; if (ok && task.restart) setWaiting(true); else if (ok) onChanged(); else toast('failed — the output says where', 'err'); setTask(null) }} />}
</>
)
}
// ---- editor / viewer ----
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
@@ -118,6 +188,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const [drag, setDrag] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const canSudo = useRef(true)
const [previews, setPreviews] = useState<boolean>(() => { try { return localStorage.getItem(PREVIEWS_KEY) === '1' } catch { return false } })
const [thumbs, setThumbs] = useState<ThumbStatus | null>(null)
const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews])
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_dir: '' })) }, [sudo])
useEffect(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
const load = useCallback(async (p = path, s = sudo) => {
try {
@@ -202,6 +279,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
<div className="files-tools">
<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' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${thumbs.summary}` : '')} 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>
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn" onClick={mkdir}>+ folder</button>
@@ -213,10 +291,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
</div>
</div>
{previews && <ThumbNote st={thumbs} sudo={sudo} onChanged={() => readThumbs(true)} />}
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
<div className="file-list">
<div className={'file-list' + (previews ? ' thumbs' : '')} ref={setListEl}>
{entries === null && <Loading label="Listing…" />}
{path !== '/' && entries !== null && (
<div className="file-row up" onClick={() => go(path.slice(0, path.lastIndexOf('/')) || '/')}><span className="ficon">↩</span><span className="fname">..</span></div>
@@ -226,7 +305,9 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const isDir = e.type === 'dir'
return (
<div key={e.name} className={'file-row' + (e.broken ? ' broken' : '')} onClick={() => (isDir ? go(full) : setOpen(full))}>
<span className="ficon">{icon(e)}</span>
{previews && e.preview && thumbs?.ok
? <Thumb url={api.fileThumbURL(full, sudo, e.mtime, e.size)} kind={e.preview} near={near} fallback={icon(e)} />
: <span className="ficon">{icon(e)}</span>}
<span className="fname" title={e.target ? `→ ${e.target}` : e.name}>{e.name}{e.target && <span className="muted"> → {e.target}</span>}</span>
<span className="fmeta num">{isDir ? '' : fmtBytes(e.size)}</span>
<span className="fmeta num">{fmtTime(e.mtime)}</span>