diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 971d0399..8bc1fa35 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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); diff --git a/src/servers/api/file-browser/thumbnails.ts b/src/servers/api/file-browser/thumbnails.ts index 2d5780ba..80c017d2 100644 --- a/src/servers/api/file-browser/thumbnails.ts +++ b/src/servers/api/file-browser/thumbnails.ts @@ -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' }; } diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 67cc0236..e7fdf5f0 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -629,7 +629,18 @@ export const FileItem = ({ ); - 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(() => { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx index dd51498e..e92c1bf7 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx @@ -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 | 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' }`} diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx index f3bbe113..bef97f83 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx @@ -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 = () => { {getArchiveBaseName(fileName)} ) : fileType === 'image' ? ( - + ) : fileType === 'video' ? ( ) : fileType === 'audio' ? ( diff --git a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts index 61810bdc..24b56036 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts @@ -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 `` 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 ', '') ?? '';