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' };
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -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(() => {
|
||||
|
||||
+32
-2
@@ -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 ', '') ?? '';
|
||||
|
||||
Reference in New Issue
Block a user