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)}`; +};