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:
@@ -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);
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user