HEIC opens, thumbnails retry, and both views ask for the same picture

three reports, three causes, none of them the one I would have guessed.

"FAILED TO LOAD IMAGE" ON DOUBLE-CLICK. the viewer pointed `<img>` at /raw — the
HEIC bytes — and no browser decodes those. `render` was declared on av-club and
nothing called it. so core gains /file-browser/render, which forwards a CLAIMED
type to the plugin that claimed it, and the viewer uses it for exactly the formats
no browser can show.

that list lives client-side and is a fact about BROWSERS, not about plugins:
chrome and firefox have never decoded HEIC and safari only does on apple's own
platforms. everything else keeps using /raw, which streams, supports ranges and
paints progressively — all of which a rendered copy gives up.

not cached on disk, unlike /thumb: a full-size render is large, looked at once, and
keyed on a width the viewer picks. caching it would fill a disk to make the second
look at one photograph faster.

"SOMETIMES DON'T ARRIVE THE FIRST TIME". two causes and the second one made the
first irreparable.

the client treated any error as final, so one failure meant a file icon until the
page was reloaded. it retries three times over about seven seconds now, backing off,
with the attempt in the `key` so the element actually remounts — changing only the
src leaves the browser's own failed-request cache in play and the retry is not one.

and the SERVER remembered the failure. that is right for sharp and ffmpeg, where the
file is the problem and will fail identically forever — it is what stops one bad
video spawning work on every scroll. it is wrong for a PLUGIN, which fails while it
restarts, while it is busy behind its own limit, and for a few seconds after an
update. those pass. cached, no client retry could ever get past them, because the
answer came from memory without the plugin being asked again. producer failures are
no longer remembered.

"THE THUMBNAIL SHOULD BE THE SAME IN LIST OR GRID". it was not, and that was mine:
the list asked for 128px and the grid for 256, which are different cache keys. for a
jpeg that is milliseconds. for a HEIC it is four seconds per file, so switching views
showed icons where the other view had just shown photographs. one size now — the list
draws at 28px and the grid at 80, so both downscale either way and 256 is sharp on a
retina screen at both.

verified: render 589KB webp from a 1.4MB HEIC, png correctly 415, thumb 12KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 08:00:00 +01:00
co-authored by Claude Opus 5
parent 111618bd7f
commit 21665ed024
6 changed files with 132 additions and 6 deletions
+37
View File
@@ -441,6 +441,43 @@ router.get('/thumb', async (ctx) => {
});
});
/**
* A displayable version of a file the browser cannot show itself.
*
* Only for types an installed plugin CLAIMS — everything a browser can already decode keeps going
* through `/raw`, which streams bytes, supports ranges and lets the browser render progressively. This
* route exists for exactly one situation: the file is an image, and no browser can display it.
*
* Not cached on disk like `/thumb`. A full-size render is large, viewed once, and keyed on a width the
* viewer chooses — caching it would fill the disk to make the second look at one photograph faster. The
* short `max-age` lets the browser hold it while somebody zooms.
*/
router.get('/render', 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 claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
// 415 and not 404: the file is there. Nothing installed can show it, which is a different answer and
// the one the viewer needs in order to say so rather than spin.
if (!claimed?.render) return ctx.text('nothing installed can display this file type', 415);
const asked = Number(ctx.req.query('width'));
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 2048;
const res = await sidecarFetch(`${claimed.render}?path=${encodeURIComponent(absPath)}&width=${width}`, user.email);
if (!res.ok) return ctx.text(`${claimed.plugin} could not read this file`, 422);
return new Response(await res.arrayBuffer(), {
headers: {
'Content-Type': res.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'private, max-age=300',
},
});
});
router.get('/raw', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
+13 -1
View File
@@ -272,7 +272,19 @@ export async function buildThumbnail(
}
if (!ok) {
rememberFailure(key);
/*
A PRODUCER'S FAILURE IS NOT REMEMBERED, and that distinction is the whole point of this branch.
When sharp or ffmpeg fails, the file is the problem — it will fail identically forever, and
remembering that is what stops one bad video spawning work on every scroll.
A producer is a PLUGIN, in another process, reachable over a socket. It fails while it restarts,
while it is busy behind its own concurrency limit, and for the few seconds after an update. Those
all pass. Caching them meant a folder of HEIC came back as file icons and stayed that way until
the SERVER was restarted — no client retry could ever get past it, because the answer was being
served from memory without the plugin being asked again.
*/
if (!produce) rememberFailure(key);
return { ok: false, reason: 'could not read an image from this file' };
}
@@ -629,7 +629,18 @@ export const FileItem = ({
</Thumbnail>
);
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : icon;
/*
ONE SIZE FOR BOTH VIEWS, and it is 256 rather than the 128 the list used to ask for.
The size is part of the cache key, so two sizes meant two entries — and switching from grid to list
re-rendered every picture from scratch. For a jpeg that is a few milliseconds nobody notices. For a
HEIC, which a plugin decodes, it is four seconds per file: the list showed icons where the grid had
just shown photographs, and the two views appeared to disagree about the same file.
The list draws it at 28px and the grid at about 80, so both are downscaling either way and 256 is
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
*/
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
useEffect(() => {
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Play } from 'lucide-react';
import { getHeaders } from 'hooks/useClient';
@@ -33,6 +33,21 @@ const API_URL = '/api';
* every one of them would spawn an ffmpeg for something nobody has looked at.
*/
/*
How many times to try before settling for the icon, and how long to wait between.
A thumbnail can legitimately fail for a REASON THAT PASSES: the plugin that renders it is restarting,
the server is busy behind its concurrency cap, or — for a format that needs decoding — the first
request is simply slow enough that something upstream gave up. Treating the first failure as final
meant a folder of HEIC came back as file icons and stayed that way until somebody reloaded the page,
which is what made it look like a race rather than a retry that never happened.
Backing off rather than hammering: a genuinely undecodable file must not become a request loop, and
three attempts over about seven seconds is enough to outlast a sidecar restart without becoming one.
*/
const MAX_ATTEMPTS = 3;
const RETRY_DELAYS_MS = [1200, 3000, 6000];
export const Thumbnail = ({
path,
root,
@@ -53,6 +68,12 @@ export const Thumbnail = ({
children: React.ReactNode;
}) => {
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
const [attempt, setAttempt] = useState(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// A pending retry must not fire into an unmounted row — the grid is virtualised, so scrolling past a
// slow thumbnail unmounts it mid-wait.
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
if (state === 'failed') return <>{children}</>;
@@ -72,8 +93,17 @@ export const Thumbnail = ({
loading="lazy"
decoding="async"
draggable={false}
// `attempt` is in the key, so a retry REMOUNTS the element. Changing only the src would leave
// the browser's own failed-request cache in play and the second attempt would not be one.
key={attempt}
onLoad={() => setState('ready')}
onError={() => setState('failed')}
onError={() => {
if (attempt + 1 >= MAX_ATTEMPTS) {
setState('failed');
return;
}
timer.current = setTimeout(() => setAttempt((n) => n + 1), RETRY_DELAYS_MS[attempt] ?? 6000);
}}
className={`size-full object-cover transition-opacity duration-150 ${
state === 'ready' ? 'opacity-100' : 'opacity-0'
}`}
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { Loader2, FolderArchive, FileWarning, Download } from 'lucide-react';
import { getLang, getRawUrl, getArchiveBaseName } from './file-types';
import { getLang, getRawUrl, getRenderUrl, needsServerRender, getArchiveBaseName } from './file-types';
import { useFileViewer } from './FileViewerContext';
import { PdfRenderer } from './renderers/PdfRenderer';
import { ImageRenderer } from './renderers/ImageRenderer';
@@ -63,7 +63,15 @@ export const FileViewerBody = () => {
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
</div>
) : fileType === 'image' ? (
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
<ImageRenderer
/*
HEIC and friends go through the server, because no browser decodes them. Everything else
keeps using `/raw`: it streams, supports ranges, and lets the browser paint progressively —
all of which a rendered copy gives up.
*/
src={needsServerRender(fileName) ? getRenderUrl(filePath, root) : getRawUrl(filePath, root)}
fileName={fileName}
/>
) : fileType === 'video' ? (
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
) : fileType === 'audio' ? (
@@ -124,6 +124,34 @@ export function formatTime(s: number): string {
return `${m}:${sec.toString().padStart(2, '0')}`;
}
/**
* Image formats NO browser can display, whatever the server does.
*
* This is a fact about browsers, not about plugins — which is why it is a constant here rather than
* something fetched. Chrome and Firefox have never decoded HEIC and Safari only does on Apple's own
* platforms, so a `<img src>` pointed at one shows a broken image everywhere it matters.
*
* The server may or may not be able to render one into something showable; that depends on whether a
* plugin claims the type, and `/file-browser/render` answers 415 when nothing does.
*/
export const BROWSER_CANNOT_DISPLAY = ['heic', 'heif'];
export const needsServerRender = (name: string): boolean =>
BROWSER_CANNOT_DISPLAY.includes(name.split('.').pop()?.toLowerCase() ?? '');
/**
* A displayable version of a file the browser cannot decode. 415 when nothing installed can make one.
*
* `width` is what the viewer will draw at, doubled for retina and clamped server-side — asking for the
* full 4032px of a phone photograph to show it in a panel is megabytes nobody looks at.
*/
export function getRenderUrl(filePath: string, root?: string, width = 2048): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/render?path=${encodeURIComponent(filePath)}&width=${width}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';