From 84336b8af5f654a303d3e43bef6324bf32263733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 00:02:24 +0100 Subject: [PATCH] a framing slot renders the original, not a rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview asked `derived/` for a 640-wide rung and drew it about 1900 pixels wide — stretched three times over, which reads as a poor photograph rather than as the wrong file. Raising the rung only moves the problem: the box is most of a panel wide and can be zoomed on top of that, so any rung chosen in advance is chosen wrong, and the widest one available is whatever the ladder happens to hold. The original has every pixel there is. It is also, often, a HEIC that no browser can decode — which is what av-club is for, and the platform already renders those for its own file browser through exactly this path. So there is a `/original/*` route: it resolves a path inside `originals/`, forwards to av-club's `/v1/render` with a width, and streams back something a browser can draw. It decodes nothing itself. That is the standing rule and the reason there is no image library in this plugin. Not cached on disk, like the platform's own /render and for the same reason: a full-size render is large, looked at once, and keyed on a width the panel chose. Caching it would fill a disk to make the second look at one photograph faster. Co-Authored-By: Claude Opus 5 (1M context) --- api/router.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++ web/MediaField.tsx | 27 +++++++++++++++++++-- web/thumbnail.ts | 18 +++++++++++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/api/router.ts b/api/router.ts index db89b87..51f7f24 100644 --- a/api/router.ts +++ b/api/router.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { createRouter, type HonoVariables } from '@@/create-router'; import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; +import { sidecarPort } from '@@/sidecar-registry'; import { readSettings, saveRootPath } from './settings'; import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website'; import { readMediaStatus, createMediaDir } from './media'; @@ -306,6 +307,65 @@ router.get('/derived/*', async (ctx) => { }); }); +/** + * An ORIGINAL, rendered at a width, for the admin to draw at size. + * + * ── Why not the derived ladder ── + * + * A framing slot is most of the panel wide — around 1900 CSS pixels — and can be zoomed into on top of + * that. Asking `derived/` for a rung means choosing one in advance and being wrong: 640 stretched three + * times over reads as a poor photograph rather than as the wrong file, and the widest rung available is + * whatever the ladder happens to hold. + * + * The original has every pixel there is. It is also, often, something a browser cannot decode — a HEIC + * off a phone — which is exactly what av-club is for. + * + * ── Every encoder is av-club ── + * + * `/v1/render` takes a path and a width and answers with something a browser can draw, whatever went + * in. This route resolves the path inside `originals/` and forwards; it decodes nothing itself, which + * is the standing rule and the reason there is no image library in this plugin. + */ +router.get('/original/*', async (ctx) => { + const user = ctx.get('user'); + const settings = await readSettings(user.email); + if (!settings.rootPath) return ctx.json({ error: 'No root location is set.' }, 400); + + const url = new URL(ctx.req.url); + const after = url.pathname.slice(url.pathname.indexOf('/original/') + '/original/'.length); + const relative = decodeURIComponent(after); + + const base = join(settings.rootPath, 'media', 'originals'); + const full = resolve(join(base, relative)); + // `resolve` first, then compare against the directory plus a separator as well as itself — a bare + // startsWith accepts `originals-old` for a root of `originals`. + if (full !== base && !full.startsWith(base + sep)) return ctx.json({ error: 'Not found' }, 404); + if (!(await Bun.file(full).exists())) return ctx.json({ error: 'Not found' }, 404); + + // Clamped at both ends: below 256 nothing is worth rendering, and above 4096 a panel is asking for + // more pixels than any screen it runs on. The same bounds the platform's own /render uses. + const asked = Number(url.searchParams.get('width')); + const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 1600; + + const port = sidecarPort('av-club'); + if (!port) return ctx.json({ error: 'av-club is not running.' }, 503); + + const rendered = await fetch( + `http://127.0.0.1:${port}/v1/render?path=${encodeURIComponent(full)}&width=${width}`, + { headers: { 'x-officer-user': user.email } }, + ); + if (!rendered.ok) return ctx.json({ error: 'That file could not be rendered.' }, 422); + + return new Response(await rendered.arrayBuffer(), { + headers: { + 'Content-Type': rendered.headers.get('content-type') ?? 'image/webp', + // Not cached on disk. A full-size render is large, looked at once, and keyed on a width the panel + // chose — caching it would fill a disk to make the second look at one photograph faster. + 'Cache-Control': 'private, max-age=300', + }, + }); +}); + router.get('/_config/media/files', async (ctx) => { const user = ctx.get('user'); const scope = ctx.req.query('path') ?? ''; diff --git a/web/MediaField.tsx b/web/MediaField.tsx index 92f1bc6..a69e85e 100644 --- a/web/MediaField.tsx +++ b/web/MediaField.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { MediaPicker } from './MediaPicker'; -import { posterUrl, thumbUrl } from './thumbnail'; +import { originalUrl, posterUrl } from './thumbnail'; /** * One picture or video on the page, shown at the shape the page will show it. @@ -219,7 +219,30 @@ export const MediaField = ({ return () => element.removeEventListener('wheel', onWheel); }, [framable, current, onCropChange, natural, slotAspect]); - const preview = value ? (kind === 'video' ? posterUrl(value) : thumbUrl(value, 640)) : null; + /* + 1280, not a thumbnail. + + This slot is drawn at most of the panel's width and can be zoomed into on top of that, so it is the + one place in the admin that needs real pixels. 640 was being stretched about three times over, which + reads as a poor photograph rather than as the wrong file. + + Not `full.webp`: on this library that is 4.6 MB for a single header, and an admin panel that takes a + second to show a picture is its own problem. 1280 is the widest rung most originals have. + */ + /* + The ORIGINAL, rendered at roughly what this slot draws. + + Not a rung from `derived/`. This box is most of the panel wide and can be zoomed on top of that, so + a rung picked in advance is picked wrong: 640 stretched over 1900 pixels reads as a poor photograph + rather than as the wrong file, and the widest rung available is whatever the ladder happens to hold. + av-club renders whatever the original is — including a HEIC no browser can decode — at the width + asked for. + + 1600 rather than measuring the box: the panel is a fixed fraction of a window, this is an admin + rather than a page, and a resize observer to save a few hundred kilobytes would be machinery nobody + benefits from. + */ + const preview = value ? (kind === 'video' ? posterUrl(value) : originalUrl(value, 1600)) : null; const accept = (event: React.DragEvent): string | null => { if (!event.dataTransfer.types.includes(DRAG_TYPE)) return null; diff --git a/web/thumbnail.ts b/web/thumbnail.ts index 9dd3dd2..1deaecb 100644 --- a/web/thumbnail.ts +++ b/web/thumbnail.ts @@ -15,7 +15,7 @@ import { getHeaders } from 'hooks/useClient'; * MediaField. Three copies of a URL shape is three places to be wrong about the rung when the ladder * changes — which it did, four times in a day. */ -export const thumbUrl = (path: string, width: 256 | 320 | 480 | 640 = 320): string => { +export const thumbUrl = (path: string, width: 256 | 320 | 480 | 640 | 960 | 1280 = 320): string => { const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; const encoded = path.split('/').map(encodeURIComponent).join('/'); return `/api/fofos-admin/derived/${encoded}/${width}.webp?token=${encodeURIComponent(token)}`; @@ -32,3 +32,19 @@ export const posterUrl = (path: string): string => { const encoded = path.split('/').map(encodeURIComponent).join('/'); return `/api/fofos-admin/derived/${encoded}/poster-512.webp?token=${encodeURIComponent(token)}`; }; + +/** + * An ORIGINAL, rendered at a width, for something drawn at size. + * + * The derived ladder is right for a row thumbnail and wrong for a framing slot: that is most of a panel + * wide and can be zoomed on top of it, so a rung chosen in advance is a rung that is wrong. This asks + * for the width the slot actually draws, from the file that has every pixel. + * + * `width` is a hint the server clamps to 256..4096. Ask for what will be drawn — twice it, on a screen + * that is worth it — rather than a round number. + */ +export const originalUrl = (path: string, width: number): string => { + const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `/api/fofos-admin/original/${encoded}?width=${Math.round(width)}&token=${encodeURIComponent(token)}`; +};