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);