the file browser draws pictures, in both views

images and videos get a thumbnail in grid and list. one <Thumbnail>, two sizes —
two implementations would have drifted the first time one learned something the
other did not, and the fallback is the part that would have drifted.

FFMPEG FOR BOTH, and no new dependency. a video needs a frame extracted, which
only ffmpeg does; an image only needs resizing, which sharp does faster and with
better format coverage. sharp is nevertheless not used: it sits in node_modules as
somebody else's transitive dependency and is absent from package.json, so importing
it is an undeclared dependency — the thing the frozen lockfile exists to prevent —
and declaring it is a deliberate --no-frozen-lockfile that belongs to whoever owns
the dependency list. ffmpeg is already a hard requirement of this router, so this
adds nothing to what the machine must have. the cost is HEIC and a few exotics,
which fall back to the icon.

THE CACHE KEY CONTAINS MTIME AND SIZE. a path alone would be wrong: replace a photo
under the same name and every screen keeps the old picture with nothing to clear it
but somebody who knows the cache exists. keyed on content, a changed file is a
different key — the old entry is orphaned rather than stale, and the response can be
`immutable` honestly rather than as a bet.

FAILURES ARE REMEMBERED. the browser retries a broken <img> every time it re-enters
the viewport, so without this one corrupt video runs ffmpeg on every scroll, forever.
in memory, so a restart gives it another chance after somebody installs a codec.

`ls` DECIDES what can be thumbnailed, not the client. the browser's own
NATIVE_VIDEO_EXTS is deliberately only what a <video> tag can play, which is a
different question from what ffmpeg can take a frame out of — every .mkv would have
gone without a picture for that reason alone. the entry has just been stat'd, so the
answer is a lookup on its name.

FOUND WHILE TESTING, and the reason there is a test folder: ffmpeg EXITS 0 HAVING
WRITTEN A ZERO-BYTE FILE when the seek lands past the end of the input. a 0.4s clip
gave exit 0 and 0 bytes with `-ss 1`, and exit 0 and a real JPEG without it. the
retry keyed on the exit code and so never fired, reporting every short video as
having no thumbnail. it keys on the output file now — there is one question worth
asking an image encoder and it is whether there is an image.

four ffmpeg at a time. the grid is virtualised so only what is on screen is asked
for, but scrolling fast through a folder of video queues far more than is visible,
and unbounded decoding turned a 1.9GB box into a swap storm once already.

size is snapped to 64px steps so a panel resizing by a pixel cannot mint a hundred
entries of one image. verified: 240 and 256 share a cache entry, 200 gets its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 01:25:11 +01:00
co-authored by Claude Opus 5
parent 16fa869260
commit 0d310ab7b0
7 changed files with 386 additions and 3 deletions
+61 -1
View File
@@ -9,6 +9,7 @@ import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb';
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
@@ -210,11 +211,24 @@ router.get('/ls', async (ctx) => {
if (!isInside(rootDir, fullPath)) return null;
const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory();
return {
name,
type: s.isDirectory() ? 'directory' : 'file',
type: isDirectory ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
/*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire.
The client had no way to know without its own copy, and the copies would have drifted — the
browser's `NATIVE_VIDEO_EXTS` is deliberately only the formats a <video> tag can play, which
is a different question from what ffmpeg can take a frame out of, and every .mkv in the
folder would have gone without a picture for that reason alone.
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
*/
thumb: isDirectory ? null : thumbnailKind(name),
};
}),
);
@@ -333,6 +347,52 @@ router.post('/rename', async (ctx) => {
// Serve raw file (binary-safe, for audio/video/images/download)
// Supports Range requests for audio/video seeking
/**
* A small JPEG of an image or a video, for the browser's grid and list.
*
* ── Why not just point <img> at `/raw` ──
*
* Because a directory of photographs is then a directory of full-size downloads. Thirty 8MB images to
* draw thirty 80px squares is a quarter of a gigabyte over the wire to show something the size of a
* postage stamp, and it is worse on a phone, which is where it matters. Video cannot be done that way
* at all — there is no frame until something decodes one.
*
* ── Authenticated by `?token=`, like `/raw` ──
*
* An `<img>` cannot send an Authorization header. `user-middleware` already accepts the token in the
* query for exactly this reason, so this route needs no special handling — and it stays behind the same
* gate as everything else here rather than becoming a public URL that happens to be hard to guess.
*
* ── `immutable`, honestly ──
*
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
* different file is a different URL. That is what makes a year-long cache correct rather than a bet.
*/
router.get('/thumb', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const kind = thumbnailKind(relPath);
// 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The
// client does not ask for these, so this is the answer to a stale tab rather than to normal use.
if (!kind) return ctx.text('no thumbnail for this file type', 415);
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('size')));
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
// the file, and logging it as a server error would bury the ones that are.
if (!result.ok) return ctx.text(result.reason, 422);
return new Response(Bun.file(result.path), {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=31536000, immutable',
},
});
});
router.get('/raw', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
+187
View File
@@ -0,0 +1,187 @@
import { createHash } from 'node:crypto';
import { mkdir, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
/**
* Thumbnails for the file browser — one small JPEG per file, made once and cached.
*
* ── ffmpeg for BOTH images and videos ──
*
* A video needs a frame extracted, which only ffmpeg does; an image only needs resizing, which sharp
* would do faster and with better format coverage. sharp is nevertheless not used: it is present in
* `node_modules` as somebody else's transitive dependency and is not in `package.json`, so importing
* it would be an undeclared dependency — exactly what the frozen-lockfile rule exists to prevent, and
* declaring it is a deliberate `--no-frozen-lockfile` that belongs to whoever owns the dependency list.
*
* ffmpeg is already a hard requirement of this router (subtitles, audio extraction, audio tracks), so
* using it for images too adds nothing to what the machine must have. The cost is HEIC and a few exotic
* formats, which fail and fall back to the file icon.
*
* ── The cache key contains mtime and size, and that is what makes it safe ──
*
* A path alone would be wrong: replace a photo with a different one under the same name and every
* screen keeps showing the old thumbnail, with nothing to clear it but a person who knows the cache
* exists. With mtime and size in the hash, a changed file simply has a different key — the old entry
* is orphaned rather than stale, and the response can be `immutable` because that URL's answer genuinely
* never changes.
*
* ── Failures are remembered ──
*
* A corrupt file, a codec ffmpeg does not have, a zero-byte download: each would otherwise spawn a
* process on every render, forever, for a file that will never produce a thumbnail. The browser retries
* a broken <img> whenever it re-enters the viewport, so "no cache on failure" means a directory with one
* bad video runs ffmpeg every time somebody scrolls past it.
*/
/** What the client is allowed to ask about. Anything else is answered 415 rather than attempted. */
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'ico']);
const VIDEO_EXT = new Set(['mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi', 'wmv', 'flv', 'mpg', 'mpeg', 'ts', '3gp']);
export const thumbnailKind = (name: string): 'image' | 'video' | null => {
const ext = extname(name).slice(1).toLowerCase();
if (IMAGE_EXT.has(ext)) return 'image';
if (VIDEO_EXT.has(ext)) return 'video';
return null;
};
/** Bigger than the grid tile draws, so a retina screen is not resampling upwards. */
const MAX_SIZE = 512;
const DEFAULT_SIZE = 256;
export const clampSize = (raw: string | undefined): number => {
const asked = Number(raw);
if (!Number.isFinite(asked)) return DEFAULT_SIZE;
// Snapped to a step, so a panel resizing by a pixel cannot mint a hundred cache entries of one image.
return Math.min(MAX_SIZE, Math.max(64, Math.round(asked / 64) * 64));
};
/*
How many ffmpeg processes may run at once.
The grid is virtualised, so only what is on screen is requested — but scrolling fast through a folder
of video queues far more than is visible, and each one is a process that decodes until it has a frame.
Four keeps a browse responsive on a small box; unbounded turned a 1.9GB machine into a swap storm once
already, which is the whole reason there is a number here.
*/
const MAX_CONCURRENT = 4;
let running = 0;
const waiting: Array<() => void> = [];
const acquire = async (): Promise<void> => {
if (running < MAX_CONCURRENT) {
running += 1;
return;
}
await new Promise<void>((resolve) => waiting.push(resolve));
running += 1;
};
const release = () => {
running -= 1;
waiting.shift()?.();
};
/**
* Paths that will never produce a thumbnail, by cache key — so a failure is not retried on every scroll.
*
* In memory rather than on disk: it is cheap to rebuild, and a restart is the natural moment to give a
* file another chance after somebody has installed a codec or replaced the file. Bounded, because a
* long-lived process browsing a lot of broken media should not grow without limit.
*/
const failed = new Set<string>();
const FAILED_LIMIT = 5_000;
const rememberFailure = (key: string) => {
if (failed.size >= FAILED_LIMIT) failed.clear();
failed.add(key);
};
/** A frame from a second in, so a video that opens on black does not thumbnail as a black square. */
const SEEK_SECONDS = '1';
/**
* Run ffmpeg and report whether a FRAME CAME OUT — which is not the same as whether it succeeded.
*
* ffmpeg exits 0 having written a zero-byte file when the seek lands past the end of the input.
* Observed on a 0.4s clip: `-ss 1` gave exit 0 and 0 bytes, and the same command without the seek gave
* exit 0 and a real JPEG. So an exit code cannot decide whether to retry, and the earlier version of
* this that trusted one reported every short video as having no thumbnail.
*
* The output file is the test, here and at the end. There is only one question worth asking of an
* image encoder and it is whether there is an image.
*/
const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> => {
const proc = Bun.spawn(['ffmpeg', ...args], { stdout: 'ignore', stderr: 'ignore' });
// 20s is generous for one frame and short enough that a wedged decode does not hold a slot forever.
const timer = setTimeout(() => proc.kill(), 20_000);
await proc.exited;
clearTimeout(timer);
const written = await stat(output).catch(() => null);
return written !== null && written.size > 0;
};
export type ThumbnailResult = { ok: true; path: string } | { ok: false; reason: string };
/**
* Produce (or find) the thumbnail for one file, and return where it is on disk.
*
* `userDataDir` is the caller's own data directory — the cache is per user, because the path being
* thumbnailed is resolved against that user's root and two users' `/photos/a.jpg` are different files.
*/
export async function buildThumbnail(
absPath: string,
userDataDir: string,
kind: 'image' | 'video',
size: number,
): Promise<ThumbnailResult> {
const info = await stat(absPath).catch(() => null);
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
const key = createHash('sha256').update(`${absPath}\0${info.mtimeMs}\0${info.size}\0${size}`).digest('hex');
if (failed.has(key)) return { ok: false, reason: 'no thumbnail for this file' };
/*
Two levels of fan-out on the first byte of the hash. A single flat directory of thumbnails is fine
until it is forty thousand entries, at which point `readdir` on it — which any backup or sync will
do — becomes the slowest thing on the machine.
*/
const cacheDir = join(userDataDir, 'cache', 'thumbs', key.slice(0, 2), key.slice(2, 4));
const cachePath = join(cacheDir, `${key}.jpg`);
const cached = await stat(cachePath).catch(() => null);
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
await mkdir(cacheDir, { recursive: true });
await acquire();
try {
/*
`force_original_aspect_ratio=decrease` fits inside the box without cropping, and never enlarges
past the source — a 40px icon blown up to 256 is worse than a small one. `-2` on the odd dimension
keeps it even, which the JPEG encoder requires for chroma subsampling.
*/
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
let ok: boolean;
if (kind === 'image') {
ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
} else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
// broken file. Take the first frame instead before giving up on it.
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
}
if (!ok) {
rememberFailure(key);
return { ok: false, reason: 'could not read an image from this file' };
}
return { ok: true, path: cachePath };
} finally {
release();
}
}
@@ -53,6 +53,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
visibleEntries: entries,
viewMode,
currentPath,
rootOverride,
selected,
clipboard,
handleOpen,
@@ -236,6 +237,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
key={entry.name}
entry={entry}
viewMode={viewMode}
path={entryPath(entry.name)}
root={rootOverride}
selected={selected.has(entry.name)}
anySelected={anySelected}
selectedCount={selected.size}
@@ -25,6 +25,7 @@ import {
PinOff,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { Thumbnail } from './Thumbnail';
import {
DropdownMenu,
DropdownMenuContent,
@@ -56,6 +57,10 @@ import { useFormat } from 'i18n';
export type FileItemProps = {
entry: DirEntry;
viewMode: 'grid' | 'list';
/** Root-relative path of this entry, for the thumbnail URL. */
path: string;
/** The browser's root, when it is not `home`. */
root?: string;
selected: boolean;
anySelected: boolean;
selectedCount: number;
@@ -587,6 +592,8 @@ export const FileItem = ({
onTogglePin,
isPinned,
canPaste,
path,
root,
}: FileItemProps) => {
const { t } = useTranslation();
const { date } = useFormat();
@@ -605,6 +612,26 @@ export const FileItem = ({
<MaterialFileIcon name={entry.name} className="inline-flex h-10 w-10" />
);
/*
The picture, where there is one, wrapping the icon it replaces.
`entry.thumb` comes from the server — see the note on it in useFilesAPI. Both views ask for a size
a little larger than they draw so a retina screen is not resampling upwards, and both fall back to
exactly the icon above when there is no picture to be had.
Rounded in the list and square in the grid: the list row is a line of text with a small image in it,
where a soft edge sits better beside a glyph; the grid tile is the picture, and cropping it into a
rounded box would trim content off a photograph for decoration.
*/
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}>
{node}
</Thumbnail>
);
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : icon;
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
useEffect(() => {
return () => {
if (clickTimer.current) clearTimeout(clickTimer.current);
@@ -708,7 +735,7 @@ export const FileItem = ({
onPointerDown={(ev) => ev.stopPropagation()}
>
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{icon}</span>
<span className="shrink-0">{listVisual}</span>
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRenameInput
@@ -762,7 +789,7 @@ export const FileItem = ({
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div>
{iconLarge}
{gridVisual}
{renaming ? (
<InlineRenameInput
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Play } from 'lucide-react';
import { getHeaders } from 'hooks/useClient';
// Matches , which declares its own for the same reason: one string, and no
// import cycle between the browser and the viewer.
const API_URL = '/api';
/**
* The picture of a file, where there is one — an image, or a frame from a video.
*
* ── One component for both views ──
*
* The grid and the list want the same thing at two sizes, so the only difference between them is a
* `size` and a class. Two implementations would have drifted the first time one of them learned
* something the other did not, and the fallback behaviour below is the part that would have drifted.
*
* ── It falls back to the icon, and the icon is the CHILD ──
*
* `children` is whatever the item would have drawn without a thumbnail. It is rendered until the image
* loads and again forever if it fails, so a file that cannot be thumbnailed — a codec ffmpeg does not
* have, a corrupt download, a member without file access — looks exactly like it did before this
* existed rather than like a hole in the layout.
*
* That is also why the icon is not merely hidden behind the image: an <img> that 404s draws a broken
* glyph in most browsers, and a layout that reserves space for a picture that never comes reads as a
* page still loading.
*
* ── `loading="lazy"`, on top of virtualisation ──
*
* The grid only mounts what is on screen, so most of the work is already avoided. Lazy loading covers
* the rest: the rows just past the fold are mounted by the virtualiser's overscan, and without this
* every one of them would spawn an ffmpeg for something nobody has looked at.
*/
export const Thumbnail = ({
path,
root,
size,
isVideo,
className,
children,
}: {
/** Root-relative, the same path every other file-browser call uses. */
path: string;
root?: string;
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
size: number;
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
isVideo: boolean;
className?: string;
/** The file icon — shown until the image arrives, and kept if it never does. */
children: React.ReactNode;
}) => {
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
if (state === 'failed') return <>{children}</>;
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
return (
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
{/* Underneath rather than instead of. Swapping on load would reflow the row twice per file on a
fast connection, which is visible as a shudder while a folder fills in. */}
{state === 'loading' && <span className="absolute inset-0 flex items-center justify-center">{children}</span>}
<img
src={src}
alt=""
loading="lazy"
decoding="async"
draggable={false}
onLoad={() => setState('ready')}
onError={() => setState('failed')}
className={`size-full object-cover transition-opacity duration-150 ${
state === 'ready' ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Only once the frame is actually showing — a badge over a file icon would claim the icon is a
video still, which is the one thing it is not. */}
{state === 'ready' && isVideo && (
<span className="pointer-events-none absolute right-0.5 bottom-0.5 rounded-sm bg-black/60 p-0.5">
<Play className="size-2.5 fill-white text-white" />
</span>
)}
</span>
);
};
@@ -1003,6 +1003,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
user,
// Navigation
homeRoot,
/*
The root this browser is reading, when it is not the caller's home.
Already threaded into `useFilesAPI` and the viewer params above; exposed because anything building
a URL to one of these files — a thumbnail, a download — needs the same answer, and reconstructing
it from `homeRoot` gets the panel-with-an-override case wrong in exactly the way line 440 explains.
*/
rootOverride,
currentPath,
setCurrentPath,
// Null when this browser does not own the address bar — the breadcrumb then stays buttons.
@@ -198,6 +198,13 @@ export type DirEntry = {
type: 'file' | 'directory';
size: number;
modifiedAt: number;
/**
* Set by `ls` when `/thumb` can draw this file, and which kind it is.
*
* The server decides, so there is one list of extensions instead of one per side. Absent on an older
* response, which simply means no thumbnail — the file icon, exactly as before.
*/
thumb?: 'image' | 'video' | null;
};
type ListDirResponse = {