diff --git a/API.md b/API.md index 22c60c56..f6ccbb56 100644 --- a/API.md +++ b/API.md @@ -105,8 +105,14 @@ status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and thumbnail, waveform, cover or attachment takes the owner's media token as `?token=`. -JSON answers of a kilobyte or more are compressed, in brotli or gzip, for a -client that accepts it. +An image the API serves, an avatar, a thumbnail, a cover, an attachment or a +remote image, takes `?w=` and answers a WebP copy scaled down to that width, +snapped up to one of `shared/image.ts`'s (128, 320, 640, 960), and never wider +than the image; without it, the image as it was uploaded, a dump's thumbnail +alone being scaled to the widest by default. `?f=compat` asks for the copy as a +JPEG or PNG, for a mail client that reads no WebP. An image no copy can be made +of goes out as it is. JSON answers of a kilobyte or more are compressed, in +brotli or gzip, for a client that accepts it. ### me diff --git a/README.md b/README.md index 028a2865..347f2e7d 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ api/ test/ # Backend tests (Deno): test//.test.ts sql/ gerbeur.db # SQLite database (not committed); .db per dev instance - uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); / per dev instance + uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); / per dev instance src/ # React frontend (Vite) config/ # API base URL, default feed tab, player mode, instance settings pages/ # One component per route, nothing else diff --git a/api/config.ts b/api/config.ts index 5ae743b7..79fa890a 100644 --- a/api/config.ts +++ b/api/config.ts @@ -120,6 +120,12 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; * thumbnails route generates from file dumps. */ export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; +/** + * Holds the scaled-down copies of the images above, which lib/ffmpeg.ts makes + * as they are first asked for, each at its upload's path with its width. It + * can be emptied at any time: the copies are made again. + */ +export const SCALED_DIR = `${UPLOADS_DIR}/scaled`; /** * Caps the size of an image upload, in bytes, for avatars, covers, thumbnails diff --git a/api/db/startup.ts b/api/db/startup.ts index deb350a0..5123e956 100644 --- a/api/db/startup.ts +++ b/api/db/startup.ts @@ -11,6 +11,7 @@ import { ORPHANED_ATTACHMENTS_RETENTION_HOURS, UNUSED_INVITES_RETENTION_DAYS, } from "../config.ts"; +import { removeScaledImages } from "../lib/ffmpeg.ts"; import { hashPassword } from "../lib/password.ts"; /** @@ -60,6 +61,7 @@ export async function runStartupTasks(db: DatabaseSync): Promise { if (orphanedAttachments.length > 0) { for (const { id } of orphanedAttachments) { await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {}); + await removeScaledImages(`${ATTACHMENTS_DIR}/${id}`); } db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`) .run(attachmentCutoff); diff --git a/api/lib/ffmpeg.ts b/api/lib/ffmpeg.ts index ae7eb998..225a6c81 100644 --- a/api/lib/ffmpeg.ts +++ b/api/lib/ffmpeg.ts @@ -1,15 +1,26 @@ /** - * Makes what the server derives from a file dump's upload with ffmpeg: a - * video's still and an image's scaled-down copy, which the thumbnails route - * serves, and an audio file's waveform, which the waveform route serves. Each - * is made on its first request and kept in `THUMBNAILS_DIR` under a name this - * module gives, until dump-service replaces or deletes the upload and - * {@link removeGeneratedMedia} drops them. + * Makes what the server derives from an upload with ffmpeg: a video's still, + * which the thumbnails route serves, an audio file's waveform, which the + * waveform route serves, and the scaled-down copies of any image, which + * `serveImage` (lib/upload.ts) serves for every route that answers one. Each + * is made on its first request and kept under a name this module gives, a + * dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until + * dump-service replaces or deletes the upload and + * {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob} + * takes what that leaves behind. * * @module */ -import { THUMBNAILS_DIR } from "../config.ts"; +import { dirname, relative } from "node:path"; +import { + DUMPS_DIR, + SCALED_DIR, + THUMBNAILS_DIR, + UPLOADS_DIR, +} from "../config.ts"; +import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts"; +import type { Job } from "./scheduler.ts"; import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts"; import type { APIResponse } from "../../shared/api.ts"; @@ -48,7 +59,7 @@ const VIDEO_FORMATS = * Lists the formats ffmpeg may read an image as, for the reason given at * {@link VIDEO_FORMATS}. */ -const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; +const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,gif"; /** * Lists the formats ffmpeg may read an audio file as, for the reason given at * {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's. @@ -57,35 +68,50 @@ const AUDIO_FORMATS = "mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm"; /** - * Maps the image types that get a scaled-down preview to the preview's format. - * A GIF is served as it is, to keep its animation. + * Returns where the still and the waveform generated from dump `dumpId`'s + * upload are kept. */ -export const PREVIEWABLE_IMAGES: Record = { - "image/jpeg": "jpg", - "image/bmp": "jpg", - // May carry transparency, which JPEG would fill with black. - "image/png": "png", - "image/webp": "png", -}; - -/** - * Caps an image preview's width. It covers the widest feed card on a - * high-density screen, at a fraction of a phone photo's bytes. - */ -const PREVIEW_WIDTH = 960; - -/** Returns where the media generated from dump `dumpId`'s upload are kept. */ export function generatedPaths(dumpId: string): { still: string; - preview: Record<"jpg" | "png", string>; waveform: string; } { const base = `${THUMBNAILS_DIR}/${dumpId}`; - return { - still: `${base}.jpg`, - preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` }, - waveform: `${base}.waveform.json`, - }; + return { still: `${base}.jpg`, waveform: `${base}.waveform.json` }; +} + +/** + * The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG + * for what doesn't read it, as some mail clients don't. + */ +export type ScaledFormat = "webp" | "jpg" | "png"; +const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"]; + +/** + * Returns where the copy of the upload at `srcPath` scaled to `width` is + * kept, in `format`: under `SCALED_DIR`, at the upload's own path below + * `UPLOADS_DIR`. + */ +export function scaledImagePath( + srcPath: string, + width: ImageWidth, + format: ScaledFormat = "webp", +): string { + return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`; +} + +/** + * Deletes the scaled copies of the upload at `srcPath`, once it is deleted. + * One that is replaced needs no call: its copies are made again, being older + * than it ({@link ensureGenerated}). + */ +export async function removeScaledImages(srcPath: string): Promise { + await Promise.all( + IMAGE_WIDTHS.flatMap((width) => + SCALED_FORMATS.map((format) => + Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {}) + ) + ), + ); } /** @@ -93,12 +119,11 @@ export function generatedPaths(dumpId: string): { * has replaced or deleted it. The routes make them again from a new one. */ export async function removeGeneratedMedia(dumpId: string): Promise { - const { still, preview, waveform } = generatedPaths(dumpId); - await Promise.all( - [still, preview.jpg, preview.png, waveform].map((path) => - Deno.remove(path).catch(() => {}) - ), - ); + const { still, waveform } = generatedPaths(dumpId); + await Promise.all([ + ...[still, waveform].map((path) => Deno.remove(path).catch(() => {})), + removeScaledImages(`${DUMPS_DIR}/${dumpId}`), + ]); } /** @@ -109,7 +134,7 @@ async function writeAtomically( outPath: string, write: (tmpPath: string) => Promise, ): Promise { - await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); + await Deno.mkdir(dirname(outPath), { recursive: true }); const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`; try { await write(tmpPath); @@ -156,25 +181,87 @@ export function videoStill(srcPath: string, outPath: string): Promise { ], outPath); } -/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */ -export function imagePreview(srcPath: string, outPath: string): Promise { +/** + * Maps a JPEG's EXIF orientation to the filters that turn its pixels upright. + * A browser applies the orientation to the upload itself; the copy carries no + * EXIF, so it is turned here, and not by ffmpeg's own autorotation, which + * only its recent versions apply to images. + */ +const ORIENTATION_FILTERS: Record = { + 2: "hflip", + 3: "hflip,vflip", + 4: "vflip", + 5: "transpose=0", + 6: "transpose=1", + 7: "transpose=3", + 8: "transpose=2", +}; + +/** What ffmpeg is told to write each {@link ScaledFormat} with. */ +const ENCODERS: Record = { + webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"], + // A still: the first frame of an animation. + jpg: ["-frames:v", "1", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2"], + png: ["-frames:v", "1", "-c:v", "png", "-f", "image2"], +}; + +/** + * Writes a copy of an image at most `width` wide, never wider than the image + * itself, as a WebP unless `format` names another. WebP keeps a PNG's + * transparency and a GIF's animation, at a fraction of either's bytes. + * `orientation` is the EXIF orientation of a JPEG, as `scalableImage` + * (lib/upload.ts) reads it. + */ +export function scaleImage( + srcPath: string, + outPath: string, + width: ImageWidth, + orientation = 1, + format: ScaledFormat = "webp", +): Promise { + const upright = ORIENTATION_FILTERS[orientation]; + // A JPEG's colour planes halve its height, which they need even. + const height = format === "jpg" ? -2 : -1; return runFfmpeg([ + "-noautorotate", "-format_whitelist", IMAGE_FORMATS, "-i", srcPath, - "-frames:v", - "1", "-vf", - `scale='min(${PREVIEW_WIDTH},iw)':-2`, - "-f", - "image2", - ...(outPath.endsWith(".png") - ? ["-c:v", "png"] - : ["-c:v", "mjpeg", "-q:v", "4"]), + `${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`, + "-an", + ...ENCODERS[format], ], outPath); } +/** + * Returns `bytes`, an image held in memory, scaled as {@link scaleImage} + * does, or null when it can't be, because ffmpeg is missing or failed. The + * image proxy scales what it fetched with it, and keeps the result itself. + */ +export async function scaleImageBytes( + bytes: Uint8Array, + width: ImageWidth, + orientation = 1, +): Promise { + if (!await ffmpegAvailable()) return null; + const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`; + const outPath = `${srcPath}.webp`; + try { + await Deno.mkdir(dirname(srcPath), { recursive: true }); + await Deno.writeFile(srcPath, bytes); + await scaleImage(srcPath, outPath, width, orientation); + return await Deno.readFile(outPath); + } catch { + return null; + } finally { + await Promise.all( + [srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})), + ); + } +} + /** * The sample rate the audio is decoded at for its waveform. A bar stands for * a second or more of a song, so 8 kHz keeps every peak worth drawing at a @@ -269,20 +356,140 @@ export function audioWaveform(srcPath: string, outPath: string): Promise { */ const inFlight = new Map>(); +/** + * Spares an ffmpeg run per request for an image it can't read: the + * generations that failed, by output path, with the date of the source they + * failed on. A new upload gets a new try. + */ +const failed = new Map(); + +/** Returns the date `path` was last written, or null when it doesn't exist. */ +async function writtenAt(path: string): Promise { + try { + return (await Deno.stat(path)).mtime?.getTime() ?? 0; + } catch { + return null; + } +} + /** * Makes `outPath` with `generate` unless it exists. Resolves false when it * can't be made, because ffmpeg is missing or failed. + * + * With `srcPath`, the file it is made from, `outPath` takes that file's date, + * and is made again when the two differ: an upload replaced in place, as an + * avatar or a cover is, gets new copies without anyone deleting the old. + * Without it, the caller deletes `outPath` when its source changes. */ export async function ensureGenerated( outPath: string, generate: () => Promise, + srcPath?: string, ): Promise { - if (await Deno.stat(outPath).then(() => true, () => false)) return true; + const made = await writtenAt(outPath); + let version: number | null = null; + if (srcPath) { + version = await writtenAt(srcPath); + if (version === null) return false; + if (made === version) return true; + if (failed.get(outPath) === version) return false; + } else if (made !== null) { + return true; + } if (!await ffmpegAvailable()) return false; let pending = inFlight.get(outPath); if (!pending) { - pending = generate().finally(() => inFlight.delete(outPath)); + pending = generate() + .then(() => + version === null + ? undefined + : Deno.utime(outPath, new Date(), new Date(version)) + ) + .catch((err) => { + if (version !== null) failed.set(outPath, version); + throw err; + }) + .finally(() => inFlight.delete(outPath)); inFlight.set(outPath, pending); } return await pending.then(() => true, () => false); } + +/** + * How old a temporary file must be for the sweep to take it: one being + * written, by a generation under way, is seconds old, and one a crash left + * is never finished. + */ +const ABANDONED_AFTER_MS = 60 * 60_000; + +/** Yields the path of every file under `dir`, and nothing when it is missing. */ +async function* filesUnder(dir: string): AsyncGenerator { + let entries: Deno.DirEntry[]; + try { + entries = await Array.fromAsync(Deno.readDir(dir)); + } catch { + return; + } + for (const entry of entries) { + const path = `${dir}/${entry.name}`; + if (entry.isDirectory) yield* filesUnder(path); + else if (entry.isFile) yield path; + } +} + +/** + * Deletes the generated files nothing serves any more, and returns how many: + * the scaled copies of an upload that is gone, which the services' own + * removals missed or never made, as for a user's avatar; the previews of + * image dumps under their former name, `.preview.jpg` or `.png`, which + * the scaled copies replaced; and the temporary files a generation left + * unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom + * thumbnails kept there being uploads. + */ +export async function sweepGeneratedMedia(): Promise { + let removed = 0; + const remove = (path: string) => + Deno.remove(path).then(() => { + removed++; + }, () => {}); + const abandoned = async (path: string) => { + const at = await writtenAt(path); + return at !== null && Date.now() - at > ABANDONED_AFTER_MS; + }; + + for await (const path of filesUnder(THUMBNAILS_DIR)) { + if ( + /\.preview\.(jpg|png)$/.test(path) || + (path.endsWith(".tmp") && await abandoned(path)) + ) await remove(path); + } + + for await (const path of filesUnder(SCALED_DIR)) { + // A copy is named after its upload's path: see scaledImagePath. + const copy = relative(SCALED_DIR, path).match( + /^(.+)\.w\d+\.(?:webp|jpg|png)$/, + ); + const unused = copy + ? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null + : await abandoned(path); + if (unused) await remove(path); + } + return removed; +} + +/** + * Runs {@link sweepGeneratedMedia} a minute after the server starts, which + * clears what a new version no longer uses as soon as it is deployed, then + * once a day. `main.ts` starts it. + */ +export const generatedMediaSweepJob: Job = { + name: "media-sweep", + everyMs: 24 * 60 * 60_000, + firstAfterMs: 60_000, + async run() { + const removed = await sweepGeneratedMedia(); + if (removed > 0) { + console.log(`[media-sweep] removed ${removed} generated files unused`); + } + }, +}; diff --git a/api/lib/upload.ts b/api/lib/upload.ts index 9420e5eb..cb90b9cc 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -1,6 +1,7 @@ /** - * Checks the images users upload and serves them back. The avatar, playlist - * cover, dump thumbnail and attachment routes call it. + * Checks the images users upload and serves them back, scaled down to the + * width a request names. The avatar, playlist cover, dump thumbnail and + * attachment routes call it. * * @module */ @@ -10,6 +11,13 @@ import { APIException } from "./api-exception.ts"; import { serveFile } from "./serve-file.ts"; import { dirname } from "node:path"; import { MAX_IMAGE_SIZE_BYTES } from "../config.ts"; +import { type ImageWidth, parseImageWidth } from "../../shared/image.ts"; +import { + ensureGenerated, + type ScaledFormat, + scaledImagePath, + scaleImage, +} from "./ffmpeg.ts"; /** * Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic @@ -62,10 +70,178 @@ export function validateImageUpload(data: Uint8Array): string { } /** - * Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds, - * and only by the browser when `isPrivate`. It answers 404 and resolves false - * when the file is missing. The caller checks first that the viewer may see - * it. + * Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its + * bytes, and 1, upright, when it states none. A phone stores a photo as its + * sensor saw it and says here how to turn it. + */ +function jpegOrientation(data: Uint8Array): number { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + // The segments follow the two-byte SOI, each a marker and its length. + let at = 2; + while (at + 4 <= data.length && data[at] === 0xFF) { + const marker = data[at + 1]; + const length = view.getUint16(at + 2); + // APP1 holding "Exif\0\0", then a TIFF header and its first directory. + if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) { + const tiff = at + 10; + if (tiff + 8 > data.length) return 1; + const little = view.getUint16(tiff) === 0x4949; + const ifd = tiff + view.getUint32(tiff + 4, little); + if (ifd + 2 > data.length) return 1; + const entries = view.getUint16(ifd, little); + for (let i = 0; i < entries; i++) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > data.length) return 1; + if (view.getUint16(entry, little) === 0x0112) { + const value = view.getUint16(entry + 8, little); + return value >= 1 && value <= 8 ? value : 1; + } + } + return 1; + } + // The image data starts: no EXIF came before it. + if (marker === 0xDA) return 1; + at += 2 + length; + } + return 1; +} + +/** + * How much of a file {@link scalableImage} needs: the EXIF segment, which + * comes first in a JPEG, is 64 KB at most. + */ +const IMAGE_HEAD_BYTES = 64 * 1024 + 32; + +/** Returned by {@link scalableImage}, for `scaleImage`. */ +export interface ScalableImage { + /** The EXIF orientation to turn the image by, 1 when it is upright. */ + orientation: number; + /** + * The format of its copy for a reader without WebP: a JPEG, or a PNG for + * an image that may be transparent, which a JPEG would fill with black. + */ + compat: "jpg" | "png"; +} + +/** + * Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are + * `head`, or null when it is to be served as it is: an animated WebP, which + * ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP. + */ +export function scalableImage(head: Uint8Array): ScalableImage | null { + const mime = detectImageMime(head); + // A file dump may be a BMP ("BM"), which no upload route here accepts. + if (!mime) { + return head[0] === 0x42 && head[1] === 0x4D + ? { orientation: 1, compat: "jpg" } + : null; + } + // "RIFF....WEBPVP8X", whose flags then say whether frames follow. + const animated = mime === "image/webp" && head[15] === 0x58 && + (head[20] & 0x02) !== 0; + if (animated) return null; + return mime === "image/jpeg" + ? { orientation: jpegOrientation(head), compat: "jpg" } + : { orientation: 1, compat: "png" }; +} + +/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */ +async function readHead(path: string): Promise { + let file: Deno.FsFile; + try { + file = await Deno.open(path, { read: true }); + } catch { + return null; + } + try { + const head = new Uint8Array(IMAGE_HEAD_BYTES); + let filled = 0; + while (filled < head.length) { + const n = await file.read(head.subarray(filled)); + if (n === null) break; + filled += n; + } + return head.subarray(0, filled); + } finally { + file.close(); + } +} + +/** The type each format of a scaled copy is served as. */ +const SCALED_MIMES: Record = { + webp: "image/webp", + jpg: "image/jpeg", + png: "image/png", +}; + +/** + * Returns the copy of the image at `path` scaled to `width`, made now unless + * it was already, as its path and type, or null when there is none to serve: + * the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or + * failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader + * without WebP takes. + */ +async function scaledCopy( + path: string, + width: ImageWidth, + compat: boolean, +): Promise<{ path: string; mime: string } | null> { + // The other format depends on the image, so it is read before the copy is + // looked for; a WebP's path is known without. + let how = compat ? await describe(path) : undefined; + if (how === null) return null; + const format: ScaledFormat = how ? how.compat : "webp"; + const scaledPath = scaledImagePath(path, width, format); + const made = await ensureGenerated(scaledPath, async () => { + how ??= await describe(path); + if (!how) throw new Error("Not an image that can be scaled"); + await scaleImage(path, scaledPath, width, how.orientation, format); + }, path); + return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null; +} + +/** Returns how to scale the image at `path`: see {@link scalableImage}. */ +async function describe(path: string): Promise { + const head = await readHead(path); + return head && scalableImage(head); +} + +/** + * Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy + * at most as wide as the request's `?w=` names (`shared/image.ts`), or as + * `defaultWidth` without one, and the image as it is when the request names + * no width or the copy can't be made. With `?f=compat`, the copy is a JPEG, + * or a PNG for an image that may be transparent, which the digest's emails + * ask for, since not every mail client reads WebP. Every route that answers + * one of the site's own images ends here, so each takes both. It resolves + * false, having sent nothing, when the file is missing. + */ +export async function serveImage( + ctx: Context, + path: string, + mime: string, + cacheControl: string, + defaultWidth?: ImageWidth, +): Promise { + // The browser must take the checked type, never guess another from the + // bytes. + ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ?? + defaultWidth; + const compat = ctx.request.url.searchParams.get("f") === "compat"; + const scaled = width ? await scaledCopy(path, width, compat) : null; + if ( + scaled && + await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl }) + ) return true; + return await serveFile(ctx, path, { mime, cacheControl }); +} + +/** + * Serves an uploaded image inline as `mime`, or scaled down as + * {@link serveImage} does, cached for `cacheMaxAge` seconds, and only by the + * browser when `isPrivate`. It answers 404 and resolves false when the file + * is missing. The caller checks first that the viewer may see it. */ export async function serveUploadedFile( ctx: Context, @@ -73,16 +249,18 @@ export async function serveUploadedFile( mime: string, cacheMaxAge = 3600, isPrivate = false, + defaultWidth?: ImageWidth, ): Promise { ctx.response.headers.set("Content-Disposition", "inline"); - // The browser must take the checked type, never guess another from the - // bytes, and a frontend on another origin may still show the image. - ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + // A frontend on another origin may still show the image. ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); - const served = await serveFile(ctx, filePath, { + const served = await serveImage( + ctx, + filePath, mime, - cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, - }); + `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, + defaultWidth, + ); if (!served) ctx.response.status = 404; return served; } diff --git a/api/main.ts b/api/main.ts index 5e8c2390..1e35516d 100644 --- a/api/main.ts +++ b/api/main.ts @@ -53,6 +53,7 @@ import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; import { startJobs } from "./lib/scheduler.ts"; import { rateLimitSweepJob } from "./lib/rate-limit.ts"; +import { generatedMediaSweepJob } from "./lib/ffmpeg.ts"; import { digestJob } from "./services/digest-service.ts"; import { communityJob } from "./services/community-service.ts"; import { pingJob, typingExpiryJob } from "./services/ws-service.ts"; @@ -141,6 +142,7 @@ if (import.meta.main) { pingJob, typingExpiryJob, rateLimitSweepJob, + generatedMediaSweepJob, outboxJob, ]); await app.listen({ hostname: LISTEN_HOST, port: PORT }); diff --git a/api/remote/image-proxy.ts b/api/remote/image-proxy.ts index 74bc4130..673f21c1 100644 --- a/api/remote/image-proxy.ts +++ b/api/remote/image-proxy.ts @@ -3,12 +3,15 @@ * http, which an https page would block, and those behind hotlink protection. * It fetches and caches the images `GET /api/remote/image` serves, which the * frontend routes a thumbnail through when it is on http or when loading it - * directly failed. + * directly failed, and scales them down as the site's own are. * * @module */ import { fetchWithTimeout, readBytesCapped } from "./fetch.ts"; +import type { ImageWidth } from "../../shared/image.ts"; +import { scaleImageBytes } from "../lib/ffmpeg.ts"; +import { scalableImage } from "../lib/upload.ts"; /** * Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends @@ -34,9 +37,9 @@ const PROXYABLE_IMAGE_TYPES = new Set([ const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; /** - * Proxied images, by URL, since every viewer of a feed asks for the same - * thumbnails. The least recently used go first, and the Map's order is that - * of use. + * Proxied images, by URL and by the width they were scaled to, since every + * viewer of a feed asks for the same thumbnails. The least recently used go + * first, and the Map's order is that of use. */ const imageCache = new Map< string, @@ -89,15 +92,34 @@ export function clearImageCache(): void { /** * Returns the image at `url`, from the cache or else from its host; null when * the host answers with an error, or with anything but a raster image of 5 MB - * at most. The route - * checks `url` with `isValidHttpUrl` first. + * at most. With `width`, it comes as a WebP copy scaled down to it, or as it + * is when no copy can be made (`scalableImage`, and ffmpeg). The route checks + * `url` with `isValidHttpUrl` first. * * @throws {Error} When the host can't be reached, as * {@link fetchWithTimeout} reports it. */ export async function fetchProxiedImage( url: string, + width?: ImageWidth | null, ): Promise { + if (!width) return await fetchOriginal(url); + + const key = `${url}#w${width}`; + const cached = readImageCache(key); + if (cached) return cached; + const original = await fetchOriginal(url); + if (!original) return null; + const how = scalableImage(original.bytes); + const bytes = how && + await scaleImageBytes(original.bytes, width, how.orientation); + if (!bytes) return original; + writeImageCache(key, bytes, "image/webp"); + return { bytes, contentType: "image/webp" }; +} + +/** Returns the image at `url` as its host sends it: see above. */ +async function fetchOriginal(url: string): Promise { const cached = readImageCache(url); if (cached) return cached; diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index fc14dd28..a05c0c2f 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -4,9 +4,10 @@ * * - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at * most 5 MB) and answers its id. - * - `GET /api/attachments/:attachmentId?token=` — public, but an image in a - * private dump or playlist, or in a comment on a private dump, needs its - * owner's media `token`. Serves the image. + * - `GET /api/attachments/:attachmentId?w=&token=` — public, but an image in + * a private dump or playlist, or in a comment on a private dump, needs its + * owner's media `token`. Serves the image, scaled down to `w` + * (`shared/image.ts`) when it names one. * * @module */ diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 8e01b364..783cf7fe 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -1,14 +1,14 @@ /** * Stores and serves the users' avatars. The profile page uploads the avatar, - * and the client's `Avatar` component loads it, with the user's `updatedAt` as - * a cache-busting version. + * and the client's `Avatar` component loads it at the size it shows, with the + * user's `updatedAt` as a cache-busting version. * * - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the * caller's user. 400 for an account from another instance, whose avatar * comes from there. The instances the caller signed in to are told. - * - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without - * one. + * - `GET /api/users/:key/avatar?w=` — public. Serves a user's avatar, scaled + * down to `w` (`shared/image.ts`) when it names one; 404 without one. * * @module */ diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index c2d076c7..89b2d56b 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -26,9 +26,9 @@ * or with `playlist:moderate`. Removes a dump. * - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB). - * - `GET /api/playlists/:playlistId/cover?token=` — public, but a private - * playlist's cover needs its owner's media `token`. Serves its cover, or 404s - * without one. + * - `GET /api/playlists/:playlistId/cover?w=&token=` — public, but a private + * playlist's cover needs its owner's media `token`. Serves its cover, scaled + * down to `w` (`shared/image.ts`) when it names one, or 404s without one. * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with * `playlist:moderate`. Reorders the dumps the caller can see in it. * diff --git a/api/routes/remote.ts b/api/routes/remote.ts index c0564f68..fb66de97 100644 --- a/api/routes/remote.ts +++ b/api/routes/remote.ts @@ -6,9 +6,9 @@ * * - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link, * and whether its page could be reached. - * - `GET /api/remote/image?url=` — public. Serves a remote raster image - * through this site; 400 when it isn't one, 502 when its host can't be - * reached. + * - `GET /api/remote/image?url=&w=` — public. Serves a remote raster image + * through this site, scaled down to `w` (`shared/image.ts`) when it names + * one; 400 when it isn't an image, 502 when its host can't be reached. * - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp * page's tracks and their stream URLs, for the native player; `force=1` * skips the server's cache. @@ -19,6 +19,7 @@ */ import { Router } from "@oak/oak"; +import { parseImageWidth } from "../../shared/image.ts"; import { isValidHttpUrl } from "../remote/fetch.ts"; import { fetchProxiedImage } from "../remote/image-proxy.ts"; import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; @@ -88,7 +89,10 @@ router.get("/image", async (ctx) => { } try { - const image = await fetchProxiedImage(url); + const image = await fetchProxiedImage( + url, + parseImageWidth(ctx.request.url.searchParams.get("w")), + ); if (!image) { ctx.response.status = 400; return; diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index 0572ac78..aefeb391 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -5,11 +5,12 @@ * from an upload are generated on first request by lib/ffmpeg.ts, which keeps * them. * - * - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's - * thumbnail needs its poster's media `token`. Serves the dump's custom - * thumbnail, else one made from its upload: a still of a video, or a - * scaled-down copy of an image (the image itself when none can be made); 404 - * when there is neither. + * - `GET /api/dumps/:dumpId/thumbnail?v=&w=&token=` — public; a private + * dump's thumbnail needs its poster's media `token`. Serves the dump's + * custom thumbnail, else one made from its upload: a still of a video, or + * its image. An image comes scaled down to `w` (`shared/image.ts`), or to + * the widest of those widths without one, and as it is when no copy can be + * made; 404 when there is neither. * - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with * `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 * MB). @@ -32,18 +33,14 @@ import { import { serveFile } from "../lib/serve-file.ts"; import { replaceUploadedFile, + serveImage, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; import { DUMPS_DIR } from "../config.ts"; +import { MAX_IMAGE_WIDTH } from "../../shared/image.ts"; import { resolveMediaDump } from "../lib/dump-media.ts"; -import { - ensureGenerated, - generatedPaths, - imagePreview, - PREVIEWABLE_IMAGES, - videoStill, -} from "../lib/ffmpeg.ts"; +import { ensureGenerated, generatedPaths, videoStill } from "../lib/ffmpeg.ts"; import { can } from "../lib/permissions.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; @@ -69,6 +66,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => { dump.thumbnailMime, 0, dump.isPrivate, + MAX_IMAGE_WIDTH, ); if (served) return; } @@ -92,29 +90,12 @@ router.get("/:dumpId/thumbnail", async (ctx) => { ) return; } - if (mime.startsWith("image/")) { - // A scaled-down copy for the feed; the original when there's none to make - // (a GIF, no ffmpeg, an image ffmpeg can't read). - ctx.response.headers.set("X-Content-Type-Options", "nosniff"); - const ext = PREVIEWABLE_IMAGES[mime]; - if (ext) { - const previewPath = generated.preview[ext]; - if ( - await ensureGenerated( - previewPath, - () => imagePreview(srcPath, previewPath), - ) && - await serveFile(ctx, previewPath, { - mime: ext === "png" ? "image/png" : "image/jpeg", - cacheControl: generatedCache, - }) - ) return; - } - if ( - /^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) && - await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache }) - ) return; - } + // A scaled-down copy for the feed; the original when there's none to make + // (no ffmpeg, an image it can't read). + if ( + /^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) && + await serveImage(ctx, srcPath, mime, generatedCache, MAX_IMAGE_WIDTH) + ) return; throw new APIException(404, "No thumbnail for this dump"); }); diff --git a/api/services/digest-service.ts b/api/services/digest-service.ts index 1e98fc1e..3f5a1f47 100644 --- a/api/services/digest-service.ts +++ b/api/services/digest-service.ts @@ -25,6 +25,7 @@ import { } from "../../shared/settings.ts"; import type { CommunityStats } from "../../shared/community.ts"; import { type Dump, dumpFileVersion } from "../../shared/dump.ts"; +import type { ImageWidth } from "../../shared/image.ts"; import { siteHost } from "../../shared/url.ts"; import { markdownToPlainText } from "../../shared/markdown.ts"; import { @@ -101,11 +102,19 @@ function quoteOf(markdown: string | undefined | null): string | undefined { : flat; } +/** + * The width the emails' thumbnails are asked at: the widest an email shows + * one, on a dense screen. + */ +const EMAIL_IMAGE_WIDTH: ImageWidth = 640; + /** * Returns the image a mail client can load for `dump`, which must be public: * the thumbnails route for a custom thumbnail or an image or video upload, - * else the link preview's image, through the image proxy when it is plain - * http, which a mail client may refuse. + * scaled to the email's width as a JPEG or PNG (`f=compat`), since not every + * mail client reads the WebP a browser gets; else the link preview's image, + * through the image proxy when it is plain http, which a mail client may + * refuse. */ function thumbnailOf(dump: Dump): string | null { const isMedia = dump.kind === "file" && @@ -113,7 +122,7 @@ function thumbnailOf(dump: Dump): string | null { if (dump.thumbnailMime || isMedia) { return `${PUBLIC_URL}/api/dumps/${dump.id}/thumbnail?v=${ dumpFileVersion(dump) - }`; + }&w=${EMAIL_IMAGE_WIDTH}&f=compat`; } const thumbnailUrl = dump.richContent?.thumbnailUrl; if (typeof thumbnailUrl !== "string") return null; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 2f9ad1f9..38debe64 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -45,7 +45,7 @@ import { } from "./notification-service.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; -import { removeGeneratedMedia } from "../lib/ffmpeg.ts"; +import { removeGeneratedMedia, removeScaledImages } from "../lib/ffmpeg.ts"; import { DUMP_ALLOWED_MIME_PREFIXES, DUMP_ALLOWED_MIME_TYPES, @@ -854,6 +854,13 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump { return updatedDump; } +/** Deletes the file of dump `dumpId`'s custom thumbnail, and its copies. */ +async function removeCustomThumbnail(dumpId: string): Promise { + const path = customThumbnailPath(dumpId); + await Deno.remove(path).catch(() => {}); + await removeScaledImages(path); +} + /** * Removes a dump's custom thumbnail and its file, for * `DELETE /api/dumps/:dumpId/thumbnail`. @@ -864,7 +871,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise { const dump = fetchDump(dumpId); db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`) .run(dump.id); - await Deno.remove(customThumbnailPath(dump.id)).catch(() => {}); + await removeCustomThumbnail(dump.id); const updatedDump: Dump = { ...dump, thumbnailMime: undefined }; announceDumpChange(dump, updatedDump); return updatedDump; @@ -891,9 +898,7 @@ export async function deleteDump(dumpId: string): Promise { await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {}); await removeGeneratedMedia(dump.id); } - if (dump.thumbnailMime) { - await Deno.remove(customThumbnailPath(dump.id)).catch(() => {}); - } + if (dump.thumbnailMime) await removeCustomThumbnail(dump.id); announceDumpChange(dump, null, holders); } diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 163a59b6..07416ff1 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -388,6 +388,60 @@ export const PNG: Uint8Array = Uint8Array.from( (c) => c.charCodeAt(0), ); +/** Whether ffmpeg runs here; the tests of what it makes are skipped without. */ +export async function hasFfmpeg(): Promise { + try { + return (await new Deno.Command("ffmpeg", { + args: ["-version"], + stdout: "null", + stderr: "null", + }).output()).success; + } catch { + return false; + } +} + +/** + * Makes a real image with ffmpeg, a `width`×`height` field of `color`, as a + * PNG, or as a GIF of three frames. + */ +export async function makeImage( + width: number, + height: number, + color = "red", + format: "png" | "gif" = "png", +): Promise> { + const { stdout } = await new Deno.Command("ffmpeg", { + args: [ + "-f", + "lavfi", + "-i", + `color=c=${color}:size=${width}x${height}:rate=3`, + "-frames:v", + format === "gif" ? "3" : "1", + "-f", + format === "gif" ? "gif" : "image2pipe", + ...(format === "gif" ? [] : ["-c:v", "png"]), + "-", + ], + stdout: "piped", + stderr: "null", + }).output(); + return new Uint8Array(stdout); +} + +/** Returns the width, in pixels, a WebP file's header states. */ +export function webpWidth(bytes: Uint8Array): number { + const kind = new TextDecoder().decode(bytes.subarray(12, 16)); + // Lossy: the frame's start code, then 14 bits of width. + if (kind === "VP8 ") return (bytes[26] | (bytes[27] << 8)) & 0x3FFF; + // Lossless: a signature byte, then 14 bits of width minus one. + if (kind === "VP8L") return ((bytes[21] | (bytes[22] << 8)) & 0x3FFF) + 1; + // Extended, as an animation or an image with transparency: the canvas's + // width minus one, on 24 bits. + return (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1; +} + export function formWithFile( bytes: Uint8Array, name = "file.png", diff --git a/api/test/lib/ffmpeg.test.ts b/api/test/lib/ffmpeg.test.ts new file mode 100644 index 00000000..51a22862 --- /dev/null +++ b/api/test/lib/ffmpeg.test.ts @@ -0,0 +1,62 @@ +import { TEST_DIR } from "../setup.ts"; +import { assertEquals } from "@std/assert"; +import { sweepGeneratedMedia } from "../../lib/ffmpeg.ts"; + +const UPLOADS = `${TEST_DIR}/uploads`; + +async function write(path: string, ageMs = 0): Promise { + const full = `${UPLOADS}/${path}`; + await Deno.mkdir(full.slice(0, full.lastIndexOf("/")), { recursive: true }); + await Deno.writeTextFile(full, "x"); + if (ageMs > 0) { + const at = new Date(Date.now() - ageMs); + await Deno.utime(full, at, at); + } +} + +const exists = (path: string) => + Deno.stat(`${UPLOADS}/${path}`).then(() => true, () => false); + +Deno.test("sweepGeneratedMedia: takes the copies of gone uploads, the old previews and abandoned temporary files", async () => { + const HOURS = 60 * 60_000; + const kept = [ + // An upload and its copies, in each format. + "avatars/u1", + "scaled/avatars/u1.w128.webp", + "scaled/avatars/u1.w640.png", + // A custom thumbnail is an upload; a still and a waveform go with their + // dump, not here. + "thumbnails/d1-custom", + "scaled/thumbnails/d1-custom.w960.webp", + "thumbnails/d1.jpg", + "thumbnails/d1.waveform.json", + // Being written right now. + "scaled/avatars/u1.w320.webp.1234.tmp", + "scaled/tmp/fresh", + "thumbnails/d1.jpg.1234.tmp", + ]; + const swept = [ + // Their upload is gone. + "scaled/avatars/u2.w128.webp", + "scaled/dumps/d2.w960.jpg", + // The name the previews of image dumps had. + "thumbnails/d1.preview.jpg", + "thumbnails/d3.preview.png", + ]; + const abandoned = [ + "scaled/avatars/u1.w960.webp.5678.tmp", + "scaled/tmp/stale", + "scaled/tmp/stale.webp", + "thumbnails/d1.waveform.json.5678.tmp", + ]; + for (const path of [...kept, ...swept]) await write(path); + for (const path of abandoned) await write(path, 2 * HOURS); + + assertEquals(await sweepGeneratedMedia(), swept.length + abandoned.length); + for (const path of kept) assertEquals(await exists(path), true, path); + for (const path of [...swept, ...abandoned]) { + assertEquals(await exists(path), false, path); + } + // Nothing left to take. + assertEquals(await sweepGeneratedMedia(), 0); +}); diff --git a/api/test/lib/upload.test.ts b/api/test/lib/upload.test.ts index 0ff80e84..2b5c89e2 100644 --- a/api/test/lib/upload.test.ts +++ b/api/test/lib/upload.test.ts @@ -1,7 +1,11 @@ import "../setup.ts"; import { PNG } from "../helpers.ts"; import { assertEquals, assertThrows } from "@std/assert"; -import { detectImageMime, validateImageUpload } from "../../lib/upload.ts"; +import { + detectImageMime, + scalableImage, + validateImageUpload, +} from "../../lib/upload.ts"; import { APIException } from "../../lib/api-exception.ts"; Deno.test("detectImageMime: sniffs magic bytes, not names", () => { @@ -40,3 +44,67 @@ Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () = big.set(PNG); assertThrows(() => validateImageUpload(big), APIException, "too large"); }); + +/** A JPEG's first bytes: SOI, then an EXIF segment stating `orientation`. */ +function jpegWithOrientation(orientation: number, little: boolean): Uint8Array { + const u16 = (n: number) => little ? [n & 0xff, n >> 8] : [n >> 8, n & 0xff]; + const u32 = (n: number) => little ? [...u16(n), 0, 0] : [0, 0, ...u16(n)]; + const tiff = [ + ...(little ? [0x49, 0x49] : [0x4d, 0x4d]), + ...u16(42), + ...u32(8), + ...u16(1), + ...u16(0x0112), + ...u16(3), + ...u32(1), + ...u16(orientation), + ...[0, 0], + ...u32(0), + ]; + const exif = [0x45, 0x78, 0x69, 0x66, 0, 0, ...tiff]; + const length = exif.length + 2; + return new Uint8Array([ + ...[0xff, 0xd8], + // A JFIF segment first, as a camera's file may have. + ...[0xff, 0xe0, 0x00, 0x04, 0x00, 0x00], + ...[0xff, 0xe1, length >> 8, length & 0xff], + ...exif, + ...[0xff, 0xda], + ]); +} + +Deno.test("scalableImage: a JPEG's EXIF orientation, upright without one", () => { + const turn = (data: Uint8Array) => scalableImage(data)?.orientation; + assertEquals(turn(jpegWithOrientation(6, false)), 6); + assertEquals(turn(jpegWithOrientation(8, true)), 8); + assertEquals(turn(jpegWithOrientation(42, true)), 1); + assertEquals(turn(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2])), 1); + // Cut short of what it announces: upright, not an error. + assertEquals(turn(jpegWithOrientation(6, false).subarray(0, 20)), 1); + assertEquals(turn(PNG), 1); +}); + +Deno.test("scalableImage: an animated WebP and what isn't an image are served as they are", () => { + const webp = (flags: number) => + new Uint8Array([ + ...new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8X"), + ...[10, 0, 0, 0], + flags, + ]); + assertEquals(scalableImage(webp(0x02)), null); + // A reader without WebP gets a PNG of what may be transparent, a JPEG of + // the rest. + assertEquals(scalableImage(webp(0x10)), { orientation: 1, compat: "png" }); + assertEquals( + scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")), + { orientation: 1, compat: "png" }, + ); + assertEquals(scalableImage(PNG), { orientation: 1, compat: "png" }); + assertEquals(scalableImage(jpegWithOrientation(1, true))?.compat, "jpg"); + assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), { + orientation: 1, + compat: "jpg", + }); + assertEquals(scalableImage(new TextEncoder().encode("")), null); + assertEquals(scalableImage(new Uint8Array()), null); +}); diff --git a/api/test/routes/avatars.test.ts b/api/test/routes/avatars.test.ts index 5cbee195..0b5d161e 100644 --- a/api/test/routes/avatars.test.ts +++ b/api/test/routes/avatars.test.ts @@ -1,4 +1,14 @@ -import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts"; +import { + createUser, + formWithFile, + get, + hasFfmpeg, + makeImage, + PNG, + put, + test, + webpWidth, +} from "../helpers.ts"; import type { SelfUser } from "../../../shared/user.ts"; import { assertEquals, assertFalse } from "@std/assert"; @@ -27,3 +37,35 @@ test("avatars: upload, serve, and 404 when there is none", async () => { 400, ); }); + +test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => { + if (!await hasFfmpeg()) return; + const user = await createUser(); + const red = await makeImage(600, 400, "red"); + await put("/api/me/avatar", { auth: user.auth, form: formWithFile(red) }); + + const small = await get(`/api/users/${user.id}/avatar?w=128`); + assertEquals(small.status, 200); + assertEquals(small.headers.get("content-type"), "image/webp"); + assertEquals(small.headers.get("cache-control"), "public, max-age=3600"); + assertEquals(webpWidth(small.bytes), 128); + // Never wider than the upload itself. + assertEquals( + webpWidth((await get(`/api/users/${user.id}/avatar?w=960`)).bytes), + 600, + ); + // Without a width, or with one that isn't a number, the upload goes out. + assertEquals((await get(`/api/users/${user.id}/avatar`)).bytes, red); + assertEquals((await get(`/api/users/${user.id}/avatar?w=big`)).bytes, red); + + await put("/api/me/avatar", { + auth: user.auth, + form: formWithFile(await makeImage(600, 400, "blue")), + }); + const again = await get(`/api/users/${user.id}/avatar?w=128`); + assertEquals(webpWidth(again.bytes), 128); + assertFalse( + again.bytes.length === small.bytes.length && + again.bytes.every((b, i) => b === small.bytes[i]), + ); +}); diff --git a/api/test/routes/dumps.test.ts b/api/test/routes/dumps.test.ts index 5f856680..a35f1235 100644 --- a/api/test/routes/dumps.test.ts +++ b/api/test/routes/dumps.test.ts @@ -5,6 +5,7 @@ import { del, formWithFile, get, + hasFfmpeg, html, insertCategory, insertComment, @@ -767,16 +768,27 @@ test("custom thumbnails: set, served, removed", async () => { }); assertEquals(set.status, 200); assertEquals(set.data.thumbnailMime, "image/png"); + // A scaled copy by default, as for an upload's own thumbnail; the image + // itself where no copy can be made. const thumb = await get(`/api/dumps/${id}/thumbnail`); assertEquals(thumb.status, 200); - assertEquals(thumb.headers.get("content-type"), "image/png"); - assertEquals(thumb.bytes, PNG); + if (await hasFfmpeg()) { + assertEquals(thumb.headers.get("content-type"), "image/webp"); + assertEquals( + readFile(`scaled/thumbnails/${id}-custom.w960.webp`) !== null, + true, + ); + } else { + assertEquals(thumb.headers.get("content-type"), "image/png"); + assertEquals(thumb.bytes, PNG); + } const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth, }); assertEquals(removed.data.thumbnailMime, undefined); assertEquals(readFile(`thumbnails/${id}-custom`), null); + assertEquals(readFile(`scaled/thumbnails/${id}-custom.w960.webp`), null); // A URL dump without its own thumbnail has none to serve. assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404); }); diff --git a/api/test/routes/remote.test.ts b/api/test/routes/remote.test.ts index a161dd70..2c57f99a 100644 --- a/api/test/routes/remote.test.ts +++ b/api/test/routes/remote.test.ts @@ -1,4 +1,12 @@ -import { get, html, PNG, stubNetwork } from "../helpers.ts"; +import { + get, + hasFfmpeg, + html, + makeImage, + PNG, + stubNetwork, + webpWidth, +} from "../helpers.ts"; import { assertEquals, assertFalse } from "@std/assert"; import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts"; @@ -29,6 +37,25 @@ Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", asyn assertEquals(svg.status, 400); }); +Deno.test("GET /api/remote/image: `?w=` scales the image, fetched once for every width", async () => { + if (!await hasFfmpeg()) return; + const wide = await makeImage(800, 200); + using net = stubNetwork(() => + new Response(wide, { headers: { "content-type": "image/png" } }) + ); + const path = `/api/remote/image?url=${ + encodeURIComponent("https://cdn.example.com/wide.png") + }`; + const small = await get(`${path}&w=128`); + assertEquals(small.status, 200); + assertEquals(small.headers.get("content-type"), "image/webp"); + assertEquals(webpWidth(small.bytes), 128); + assertEquals(webpWidth((await get(`${path}&w=320`)).bytes), 320); + assertEquals((await get(`${path}&w=128`)).bytes, small.bytes); + assertEquals((await get(path)).bytes, wide); + assertEquals(net.calls.length, 1); +}); + Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => { let failing = true; using net = stubNetwork(() => diff --git a/api/test/routes/thumbnails.test.ts b/api/test/routes/thumbnails.test.ts index edbb2e0b..f52a359b 100644 --- a/api/test/routes/thumbnails.test.ts +++ b/api/test/routes/thumbnails.test.ts @@ -1,34 +1,24 @@ import { createUser, db, + del, get, + hasFfmpeg, insertDump, + makeImage, PNG, test, TEST_DIR, + webpWidth, } from "../helpers.ts"; import { assertEquals } from "@std/assert"; -const BYTES = new TextEncoder().encode("0123456789"); - function createdAtOf(id: string): string { return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as { created_at: string; }).created_at; } -async function hasFfmpeg(): Promise { - try { - return (await new Deno.Command("ffmpeg", { - args: ["-version"], - stdout: "null", - stderr: "null", - }).output()).success; - } catch { - return false; - } -} - function readDir(path: string): string[] { try { return [...Deno.readDirSync(path)].map((e) => e.name); @@ -81,54 +71,87 @@ test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy", assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400); }); -test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => { +test("GET /api/dumps/:id/thumbnail: an image dump gets a scaled copy, or itself when none can be made", async () => { const owner = await createUser(); + const scaled = (id: string) => + readDir(`${TEST_DIR}/uploads/scaled/dumps`).filter((n) => n.startsWith(id)) + .sort(); + + // Not an image ffmpeg can read (or no ffmpeg at all): the original goes out. const gif = new TextEncoder().encode("GIF89a-not-really"); - const animated = insertDump({ + const broken = insertDump({ userId: owner.id, fileName: "a.gif", fileMime: "image/gif", fileData: gif, }); - const res = await get(`/api/dumps/${animated.id}/thumbnail`); - assertEquals(res.status, 200); - assertEquals(res.headers.get("content-type"), "image/gif"); - assertEquals(res.bytes, gif); + const fallback = await get(`/api/dumps/${broken.id}/thumbnail`); + assertEquals(fallback.status, 200); + assertEquals(fallback.headers.get("content-type"), "image/gif"); + assertEquals(fallback.bytes, gif); + assertEquals(scaled(broken.id), []); - // A real PNG is scaled into a preview, kept beside the video stills. + if (!await hasFfmpeg()) { + const photo = insertDump({ + userId: owner.id, + fileName: "p.png", + fileMime: "image/png", + fileData: PNG, + }); + const itself = await get(`/api/dumps/${photo.id}/thumbnail`); + assertEquals(itself.headers.get("content-type"), "image/png"); + assertEquals(itself.bytes, PNG); + return; + } + + // A real image is scaled to the widest width without `w`, and to the width + // asked for with it, each copy kept under the upload's own path. const photo = insertDump({ userId: owner.id, fileName: "p.png", fileMime: "image/png", - fileData: PNG, + fileData: await makeImage(1400, 300), }); - const preview = await get(`/api/dumps/${photo.id}/thumbnail`); - assertEquals(preview.status, 200); - assertEquals(preview.headers.get("content-type"), "image/png"); - if (await hasFfmpeg()) { - assertEquals( - readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) => - n.startsWith(photo.id) - ), - [`${photo.id}.preview.png`], - ); - } + const widest = await get(`/api/dumps/${photo.id}/thumbnail`); + assertEquals(widest.status, 200); + assertEquals(widest.headers.get("content-type"), "image/webp"); + assertEquals(webpWidth(widest.bytes), 960); + const small = await get(`/api/dumps/${photo.id}/thumbnail?w=128`); + assertEquals(small.headers.get("content-type"), "image/webp"); + assertEquals(webpWidth(small.bytes), 128); + // A width between two of the list's takes the one above. + const between = await get(`/api/dumps/${photo.id}/thumbnail?w=200`); + assertEquals(webpWidth(between.bytes), 320); + assertEquals(scaled(photo.id), [ + `${photo.id}.w128.webp`, + `${photo.id}.w320.webp`, + `${photo.id}.w960.webp`, + ]); - // Not an image ffmpeg can read (or no ffmpeg at all): the original goes out. - const broken = insertDump({ + // A reader without WebP, as a mail client, asks for a PNG or a JPEG. + const compat = await get(`/api/dumps/${photo.id}/thumbnail?w=640&f=compat`); + assertEquals(compat.status, 200); + assertEquals(compat.headers.get("content-type"), "image/png"); + // Its header states the width, on four bytes from the sixteenth. + assertEquals(new DataView(compat.bytes.buffer).getUint32(16), 640); + assertEquals(scaled(photo.id).includes(`${photo.id}.w640.png`), true); + + // An animation keeps its frames. + const animated = insertDump({ userId: owner.id, - fileName: "b.png", - fileMime: "image/png", - fileData: BYTES, + fileName: "anim.gif", + fileMime: "image/gif", + fileData: await makeImage(400, 300, "blue", "gif"), }); - const fallback = await get(`/api/dumps/${broken.id}/thumbnail`); - assertEquals(fallback.status, 200); - assertEquals(fallback.headers.get("content-type"), "image/png"); - assertEquals(fallback.bytes, BYTES); + const moving = await get(`/api/dumps/${animated.id}/thumbnail?w=128`); + assertEquals(moving.headers.get("content-type"), "image/webp"); + assertEquals(webpWidth(moving.bytes), 128); + assertEquals(new TextDecoder().decode(moving.bytes).includes("ANMF"), true); + + // The copies go with the dump. assertEquals( - readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) => - n.startsWith(broken.id) - ), - [], + (await del(`/api/dumps/${photo.id}`, { auth: owner.auth })).status, + 200, ); + assertEquals(scaled(photo.id), []); }); diff --git a/shared/image.ts b/shared/image.ts new file mode 100644 index 00000000..4adeb9b2 --- /dev/null +++ b/shared/image.ts @@ -0,0 +1,42 @@ +/** + * Names the widths the site's own images come in. Every image the API serves + * (avatars, covers, thumbnails, attachments, proxied images) takes `?w=` and + * answers a copy scaled down to it, so a card or an avatar doesn't load the + * upload at full size. The frontend builds those URLs from this list + * (`imageSrcSet` in `src/utils/shared/urls.ts`) and the server makes no other + * width (`serveImage` in `api/lib/upload.ts`), which bounds what it keeps. + * + * @module + */ + +/** + * The widths, in pixels, smallest first: an avatar on a dense screen, a small + * cover, a card on a phone, and the widest feed card on a dense screen. + */ +export const IMAGE_WIDTHS = [128, 320, 640, 960] as const; + +/** One of {@link IMAGE_WIDTHS}. */ +export type ImageWidth = typeof IMAGE_WIDTHS[number]; + +/** The largest of {@link IMAGE_WIDTHS}, which a thumbnail never exceeds. */ +export const MAX_IMAGE_WIDTH: ImageWidth = + IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]; + +/** + * Returns the narrowest of {@link IMAGE_WIDTHS} that covers `width` pixels, or + * the widest when none does. + */ +export function snapImageWidth(width: number): ImageWidth { + return IMAGE_WIDTHS.find((w) => w >= width) ?? MAX_IMAGE_WIDTH; +} + +/** + * Returns the width a `?w=` value asks for, snapped to {@link IMAGE_WIDTHS}, + * or null when it is absent or not a positive number, which asks for the image + * as it is. + */ +export function parseImageWidth(raw: string | null): ImageWidth | null { + if (raw === null || !/^\d{1,5}$/.test(raw)) return null; + const width = Number(raw); + return width > 0 ? snapImageWidth(width) : null; +} diff --git a/shared/test/image.test.ts b/shared/test/image.test.ts new file mode 100644 index 00000000..1c4ff2f6 --- /dev/null +++ b/shared/test/image.test.ts @@ -0,0 +1,22 @@ +import { assertEquals } from "@std/assert"; +import { parseImageWidth, snapImageWidth } from "../image.ts"; + +Deno.test("snapImageWidth: the narrowest width that covers, the widest past them all", () => { + assertEquals(snapImageWidth(1), 128); + assertEquals(snapImageWidth(128), 128); + assertEquals(snapImageWidth(129), 320); + assertEquals(snapImageWidth(700), 960); + assertEquals(snapImageWidth(5000), 960); +}); + +Deno.test("parseImageWidth: a positive number, or no width at all", () => { + assertEquals(parseImageWidth("320"), 320); + assertEquals(parseImageWidth("200"), 320); + assertEquals(parseImageWidth(null), null); + assertEquals(parseImageWidth(""), null); + assertEquals(parseImageWidth("0"), null); + assertEquals(parseImageWidth("-64"), null); + assertEquals(parseImageWidth("12.5"), null); + assertEquals(parseImageWidth("wide"), null); + assertEquals(parseImageWidth("999999999"), null); +}); diff --git a/src/components/dump/FilePreview.tsx b/src/components/dump/FilePreview.tsx index 5ad8ceae..20078be2 100644 --- a/src/components/dump/FilePreview.tsx +++ b/src/components/dump/FilePreview.tsx @@ -12,7 +12,9 @@ import { dumpFileUrl, dumpThumbnailUrl, dumpWaveformUrl, + imageProps, } from "../../utils/shared/urls.ts"; +import { IMAGE_SIZES } from "../../config/imageSizes.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { fileArtworkUrl, @@ -72,6 +74,7 @@ function FilePlayButton({ dump }: { dump: Dump }) { @@ -141,7 +144,10 @@ export function FilePreview( // A card needs the server's scaled-down copy, not the full upload. return ( {dump.fileName} @@ -135,6 +137,7 @@ export function RichContentCard( src={thumbnailSrc} alt={richContent.title ?? ""} className="rich-content-thumbnail" + sizes={IMAGE_SIZES.richThumbnail} placeholder={placeholder} placeholderClassName="rich-content-thumbnail" /> diff --git a/src/components/player/GlobalPlayer.tsx b/src/components/player/GlobalPlayer.tsx index a47eb7d3..f6b8ddfe 100644 --- a/src/components/player/GlobalPlayer.tsx +++ b/src/components/player/GlobalPlayer.tsx @@ -17,6 +17,7 @@ import { import { MediaPlayer } from "./MediaPlayer.tsx"; import { fmt } from "../../utils/player/duration.ts"; import { Thumbnail } from "../shared/Thumbnail.tsx"; +import { IMAGE_SIZES } from "../../config/imageSizes.ts"; import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts"; import { embedListId, withJsApi } from "../../utils/player/youtube.ts"; @@ -201,6 +202,7 @@ export function GlobalPlayer(): React.JSX.Element | null { onDelete(p))} privateBadgeOnly={privateBadgeOnly} + grid={grid} /> ))} diff --git a/src/components/playlist/PlaylistHeader.tsx b/src/components/playlist/PlaylistHeader.tsx index 7f6467de..2f04ac03 100644 --- a/src/components/playlist/PlaylistHeader.tsx +++ b/src/components/playlist/PlaylistHeader.tsx @@ -9,7 +9,8 @@ import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import type { PlaylistWithDumps } from "../../../shared/playlist.ts"; -import { playlistImageUrl } from "../../utils/shared/urls.ts"; +import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts"; +import { IMAGE_SIZES } from "../../config/imageSizes.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { Markdown } from "../editor/Markdown.tsx"; @@ -37,7 +38,10 @@ export function PlaylistHeader( <> {playlist.imageMime && ( diff --git a/src/components/shared/Avatar.tsx b/src/components/shared/Avatar.tsx index 29b7bbf3..bc571bbb 100644 --- a/src/components/shared/Avatar.tsx +++ b/src/components/shared/Avatar.tsx @@ -6,7 +6,7 @@ */ import { useContext, useState } from "react"; -import { userApiUrl } from "../../utils/shared/urls.ts"; +import { imageProps, userApiUrl } from "../../utils/shared/urls.ts"; import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts"; /** @@ -25,8 +25,9 @@ export interface AvatarProps { } /** - * Renders the image from `GET /api/users/:userId/avatar`, or the user's initial - * when they have none or it fails to load. An avatar added, changed or removed + * Renders the image from `GET /api/users/:userId/avatar`, scaled by the server + * to the size it is shown at, or the user's initial when they have none or it + * fails to load. An avatar added, changed or removed * since the caller's data was fetched shows at once, through the * `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`. */ @@ -55,7 +56,7 @@ export function Avatar( : userApiUrl({ id: userId }, "avatar"); return ( {username}; placeholderClassName?: string; @@ -42,6 +49,7 @@ export function Thumbnail( alt = "", className, loading = "lazy", + sizes, placeholder, placeholderClassName, }: ThumbnailProps, @@ -64,11 +72,10 @@ export function Thumbnail( ); } + const url = proxyImageUrl(src, { force: attempt === "proxied" }); return ( {alt}, + /** A playlist's cover in a row of a list. */ + playlistThumb: 80, + /** A playlist's cover as a tile of the grid, two to a row when narrow. */ + playlistTile: "(max-width: 512px) 50vw, 250px", + /** A playlist's cover as the banner of its page. */ + playlistBanner: "(max-width: 860px) 100vw, 860px", + /** The artwork in the global player's header. */ + playerArtwork: 36, +} as const; diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx index 1d215e0b..707199e9 100644 --- a/src/test/components/dump/FilePreview.test.tsx +++ b/src/test/components/dump/FilePreview.test.tsx @@ -35,7 +35,12 @@ describe("FilePreview", () => { const d = file("image/png"); renderWithContexts(, { session }); const img = screen.getByRole("img", { name: "thing" }); - expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken)); + // The copy scaled to a card's preview, on a dense screen. + expect(img).toHaveAttribute( + "src", + `${dumpThumbnailUrl(d, session.mediaToken)}&w=320`, + ); + expect(img).toHaveAttribute("sizes", "128px"); expect(img).toHaveAttribute("loading", "lazy"); }); @@ -101,7 +106,7 @@ describe("FilePreview", () => { ); expect(container.querySelector("button img")).toHaveAttribute( "src", - dumpThumbnailUrl(d), + `${dumpThumbnailUrl(d)}&w=320`, ); }); diff --git a/src/test/components/dump/JournalCard.test.tsx b/src/test/components/dump/JournalCard.test.tsx index 23e7e0ae..61c19bc3 100644 --- a/src/test/components/dump/JournalCard.test.tsx +++ b/src/test/components/dump/JournalCard.test.tsx @@ -123,8 +123,9 @@ describe("JournalCard", () => { , ); - expect(container.querySelector("img")!.getAttribute("src")).toBe( - dumpThumbnailUrl(dump), - ); + const img = container.querySelector("img")!; + expect(img.getAttribute("src")).toBe(`${dumpThumbnailUrl(dump)}&w=960`); + // A tile of one column: the browser takes a narrower copy for it. + expect(img.getAttribute("sizes")).toContain("290px"); }); }); diff --git a/src/test/components/dump/RichContentCard.test.tsx b/src/test/components/dump/RichContentCard.test.tsx index 02420441..405d6f25 100644 --- a/src/test/components/dump/RichContentCard.test.tsx +++ b/src/test/components/dump/RichContentCard.test.tsx @@ -75,7 +75,7 @@ describe("RichContentCard", () => { ); expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute( "src", - "http://localhost:8000/api/dumps/d/thumbnail", + "http://localhost:8000/api/dumps/d/thumbnail?w=960", ); }); diff --git a/src/test/components/shared/Avatar.test.tsx b/src/test/components/shared/Avatar.test.tsx index 37306907..4bb36c99 100644 --- a/src/test/components/shared/Avatar.test.tsx +++ b/src/test/components/shared/Avatar.test.tsx @@ -5,14 +5,17 @@ import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContex import { renderWithContexts } from "../../render.tsx"; describe("Avatar", () => { - test("an image when the user has one, versioned", () => { + test("an image when the user has one, versioned, at the size shown", () => { renderWithContexts( - , + , ); - expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute( + const img = screen.getByRole("img", { name: "ann" }); + expect(img).toHaveAttribute( "src", - "http://localhost:8000/api/users/u1/avatar?v=7", + "http://localhost:8000/api/users/u1/avatar?v=7&w=128", ); + expect(img).toHaveAttribute("sizes", "32px"); + expect(img.getAttribute("srcset")).toContain("avatar?v=7&w=320 320w"); }); test("initials otherwise, and after the image fails", () => { diff --git a/src/test/components/shared/Thumbnail.test.tsx b/src/test/components/shared/Thumbnail.test.tsx index 885266a2..f871ee49 100644 --- a/src/test/components/shared/Thumbnail.test.tsx +++ b/src/test/components/shared/Thumbnail.test.tsx @@ -11,17 +11,20 @@ describe("Thumbnail", () => { , ); const img = screen.getByRole("img", { name: "art" }); expect(img).toHaveAttribute("src", "https://img.test/a.png"); expect(img).toHaveAttribute("referrerpolicy", "no-referrer"); + // Another site's image comes as that site sends it. + expect(img).not.toHaveAttribute("srcset"); fireEvent.error(img); - expect(screen.getByRole("img", { name: "art" }).getAttribute("src")) - .toContain( - "/api/remote/image?url=", - ); + const proxied = screen.getByRole("img", { name: "art" }); + expect(proxied.getAttribute("src")).toContain("/api/remote/image?url="); + // Through the proxy, it is scaled as the site's own are. + expect(proxied.getAttribute("srcset")).toContain("a.png&w=128 128w"); fireEvent.error(screen.getByRole("img", { name: "art" })); expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument(); expect(screen.getByText("S")).toBeInTheDocument(); @@ -32,16 +35,24 @@ describe("Thumbnail", () => { , ); - fireEvent.error(screen.getByRole("img", { name: "t" })); + const img = screen.getByRole("img", { name: "t" }); + expect(img).toHaveAttribute( + "src", + "http://localhost:8000/api/dumps/d/thumbnail?w=960", + ); + expect(img.getAttribute("srcset")).toContain("thumbnail?w=320 320w"); + expect(img).toHaveAttribute("sizes", "(max-width: 512px) 100vw, 180px"); + fireEvent.error(img); expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); }); test("no src: straight to the placeholder; a new src tries again", () => { const { rerender } = renderWithContexts( - , + , ); expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); rerender( @@ -49,6 +60,7 @@ describe("Thumbnail", () => { src="https://img.test/b.png" placeholder={placeholder} alt="t" + sizes={128} />, ); expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument(); diff --git a/src/test/pages/PlaylistDetail.test.tsx b/src/test/pages/PlaylistDetail.test.tsx index 40663451..b454b628 100644 --- a/src/test/pages/PlaylistDetail.test.tsx +++ b/src/test/pages/PlaylistDetail.test.tsx @@ -126,7 +126,7 @@ describe("PlaylistDetail", () => { await screen.findByRole("heading", { name: "Mix" }); expect(document.querySelector(".playlist-detail-img")).toHaveAttribute( "src", - "http://localhost:8000/api/playlists/p1/cover?v=5000", + "http://localhost:8000/api/playlists/p1/cover?v=5000&w=960", ); }); @@ -324,7 +324,7 @@ describe("PlaylistDetail", () => { await waitFor(() => expect(document.querySelector(".playlist-detail-img")).toHaveAttribute( "src", - "http://localhost:8000/api/playlists/p1/cover?v=9000", + "http://localhost:8000/api/playlists/p1/cover?v=9000&w=960", ) ); }); diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index 24781261..6236c221 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -523,7 +523,7 @@ describe("UserPublicProfile", () => { expect(document.querySelector(".profile-avatar-wrapper img")) .toHaveAttribute( "src", - "http://localhost:8000/api/users/ann/avatar?v=7000", + "http://localhost:8000/api/users/ann/avatar?v=7000&w=320", ) ); }); diff --git a/src/test/utils/shared/urls.test.ts b/src/test/utils/shared/urls.test.ts index 5c3c0bfd..61177015 100644 --- a/src/test/utils/shared/urls.test.ts +++ b/src/test/utils/shared/urls.test.ts @@ -3,6 +3,8 @@ import { dumpFileUrl, dumpThumbnailUrl, dumpUrl, + imageProps, + isApiImageUrl, isAt, normalizeUrl, playlistImageUrl, @@ -65,4 +67,27 @@ describe("urls", () => { expect(playlistImageUrl({ id: "p", isPublic: false }, null)) .toBe(`${API_URL}/api/playlists/p/cover`); }); + test("imageProps asks for the copies the server makes, by the width shown", () => { + const avatar = `${API_URL}/api/users/u1/avatar`; + expect(imageProps(avatar, 32)).toEqual({ + src: `${avatar}?w=128`, + srcSet: [128, 320, 640, 960].map((w) => `${avatar}?w=${w} ${w}w`) + .join(", "), + sizes: "32px", + }); + // Twice the width shown, for a dense screen that reads only `src`. + expect(imageProps(avatar, 128).src).toBe(`${avatar}?w=320`); + // A URL with a query keeps it, and a width that follows the viewport's + // takes the widest copy as its `src`. + const cover = `${API_URL}/api/playlists/p1/cover?v=7`; + const fluid = imageProps(cover, "(max-width: 512px) 50vw, 250px"); + expect(fluid.src).toBe(`${cover}&w=960`); + expect(fluid.srcSet).toContain(`${cover}&w=640 640w`); + expect(fluid.sizes).toBe("(max-width: 512px) 50vw, 250px"); + }); + test("isApiImageUrl tells the API's images from another site's", () => { + expect(isApiImageUrl(`${API_URL}/api/dumps/d1/thumbnail`)).toBe(true); + expect(isApiImageUrl(`${API_URL}/api/remote/image?url=x`)).toBe(true); + expect(isApiImageUrl("https://img.test/a.png")).toBe(false); + }); }); diff --git a/src/utils/shared/urls.ts b/src/utils/shared/urls.ts index 5b258159..820ff277 100644 --- a/src/utils/shared/urls.ts +++ b/src/utils/shared/urls.ts @@ -14,6 +14,7 @@ import { playlistPath, } from "../../../shared/paths.ts"; import { dumpFileVersion } from "../../../shared/dump.ts"; +import { IMAGE_WIDTHS, snapImageWidth } from "../../../shared/image.ts"; /** * Passed to {@link dumpFileUrl} and {@link dumpThumbnailUrl}, usually as a @@ -166,3 +167,36 @@ export function playlistImageUrl( params.length ? `?${params.join("&")}` : "" }`; } + +/** + * Checks whether `url` is an image the API serves, its own or one it proxies, + * which all take `?w=` and answer a scaled-down copy. Another site's image, + * loaded from its own host, doesn't. + */ +export function isApiImageUrl(url: string): boolean { + return url.startsWith(`${API_URL}/api/`); +} + +/** + * Returns what an `` needs to load `url`, one of the API's images, no + * larger than it is shown: a `srcset` of the copies the server makes + * (`shared/image.ts`), from which the browser takes the narrowest that fills + * `sizes` on its screen, and a `src` for whatever reads only that. `sizes` is + * the width the image is laid out at, in CSS pixels, or a `sizes` attribute + * when that width follows the viewport's. `Avatar`, `Thumbnail` and the + * playlists' covers spread it on their image. + */ +export function imageProps( + url: string, + sizes: number | string, +): { src: string; srcSet: string; sizes: string } { + const at = (width: number) => + `${url}${url.includes("?") ? "&" : "?"}w=${width}`; + return { + // Twice the laid-out width, as most screens want; the widest when that + // width isn't known here. + src: at(snapImageWidth(typeof sizes === "number" ? sizes * 2 : Infinity)), + srcSet: IMAGE_WIDTHS.map((width) => `${at(width)} ${width}w`).join(", "), + sizes: typeof sizes === "number" ? `${sizes}px` : sizes, + }; +}