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:
co-authored by
Claude Fable 5.1
parent
e7034cd478
commit
f8c9479e93
+86
-5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user