v4: a still is scaled from its first frame alone, a second image in its file made the copy flash — an avatar came out as a WebP of two frames looping at 40 ms each, the picture then a blank. A still's file may hold more than one image, a phone's JPEG carrying a gain map or a depth map after the photo, and ffmpeg reads each as a frame: only the JPEG and PNG copies were held to one, the WebP keeping them all as it does a GIF's. scalableImage (lib/upload.ts) now says whether the image is an animation, which a GIF alone is, and scaleImage (lib/ffmpeg.ts) writes one frame of anything else, in every format; the image proxy scales through the same call. The copies already made stay, a copy being current while it has its upload's date: uploads/scaled is to be deleted once on an instance that ran the previous version, everything in it being made again on first request.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 52s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 19:40:14 +00:00
parent d2b7d21824
commit cbb770bff8
5 changed files with 68 additions and 25 deletions

View File

@@ -200,28 +200,44 @@ const ORIENTATION_FILTERS: Record<number, string> = {
/** What ffmpeg is told to write each {@link ScaledFormat} with. */ /** What ffmpeg is told to write each {@link ScaledFormat} with. */
const ENCODERS: Record<ScaledFormat, string[]> = { const ENCODERS: Record<ScaledFormat, string[]> = {
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"], webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
// A still: the first frame of an animation. jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
jpg: ["-frames:v", "1", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2"], png: ["-c:v", "png", "-f", "image2"],
png: ["-frames:v", "1", "-c:v", "png", "-f", "image2"],
}; };
/**
* What {@link scaleImage} is told of the image it scales, which
* `scalableImage` (lib/upload.ts) reads from its first bytes.
*/
export interface ImageTraits {
/** The EXIF orientation to turn the image by, 1 when it is upright. */
orientation: number;
/**
* Whether its frames are an animation, as a GIF's are. A still may hold
* more than one image too, a phone's JPEG carrying a gain map or a depth
* map after the photo, and ffmpeg reads each as a frame.
*/
animated: boolean;
}
/** /**
* Writes a copy of an image at most `width` wide, never wider than the image * 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 * 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. * 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( export function scaleImage(
srcPath: string, srcPath: string,
outPath: string, outPath: string,
width: ImageWidth, width: ImageWidth,
orientation = 1, { orientation, animated }: ImageTraits,
format: ScaledFormat = "webp", format: ScaledFormat = "webp",
): Promise<void> { ): Promise<void> {
const upright = ORIENTATION_FILTERS[orientation]; const upright = ORIENTATION_FILTERS[orientation];
// A JPEG's colour planes halve its height, which they need even. // A JPEG's colour planes halve its height, which they need even.
const height = format === "jpg" ? -2 : -1; const height = format === "jpg" ? -2 : -1;
// Only an animation keeps its frames, and only as a WebP: of anything else
// the first is the image, and a copy of them all would flash through what
// follows it.
const frames = animated && format === "webp" ? [] : ["-frames:v", "1"];
return runFfmpeg([ return runFfmpeg([
"-noautorotate", "-noautorotate",
"-format_whitelist", "-format_whitelist",
@@ -231,6 +247,7 @@ export function scaleImage(
"-vf", "-vf",
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`, `${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
"-an", "-an",
...frames,
...ENCODERS[format], ...ENCODERS[format],
], outPath); ], outPath);
} }
@@ -243,7 +260,7 @@ export function scaleImage(
export async function scaleImageBytes( export async function scaleImageBytes(
bytes: Uint8Array, bytes: Uint8Array,
width: ImageWidth, width: ImageWidth,
orientation = 1, traits: ImageTraits,
): Promise<Uint8Array | null> { ): Promise<Uint8Array | null> {
if (!await ffmpegAvailable()) return null; if (!await ffmpegAvailable()) return null;
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`; const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
@@ -251,7 +268,7 @@ export async function scaleImageBytes(
try { try {
await Deno.mkdir(dirname(srcPath), { recursive: true }); await Deno.mkdir(dirname(srcPath), { recursive: true });
await Deno.writeFile(srcPath, bytes); await Deno.writeFile(srcPath, bytes);
await scaleImage(srcPath, outPath, width, orientation); await scaleImage(srcPath, outPath, width, traits);
return await Deno.readFile(outPath); return await Deno.readFile(outPath);
} catch { } catch {
return null; return null;

View File

@@ -14,6 +14,7 @@ import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts"; import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
import { import {
ensureGenerated, ensureGenerated,
type ImageTraits,
type ScaledFormat, type ScaledFormat,
scaledImagePath, scaledImagePath,
scaleImage, scaleImage,
@@ -113,9 +114,7 @@ function jpegOrientation(data: Uint8Array): number {
const IMAGE_HEAD_BYTES = 64 * 1024 + 32; const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
/** Returned by {@link scalableImage}, for `scaleImage`. */ /** Returned by {@link scalableImage}, for `scaleImage`. */
export interface ScalableImage { export interface ScalableImage extends ImageTraits {
/** 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 * 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. * an image that may be transparent, which a JPEG would fill with black.
@@ -133,16 +132,16 @@ export function scalableImage(head: Uint8Array): ScalableImage | null {
// A file dump may be a BMP ("BM"), which no upload route here accepts. // A file dump may be a BMP ("BM"), which no upload route here accepts.
if (!mime) { if (!mime) {
return head[0] === 0x42 && head[1] === 0x4D return head[0] === 0x42 && head[1] === 0x4D
? { orientation: 1, compat: "jpg" } ? { orientation: 1, animated: false, compat: "jpg" }
: null; : null;
} }
// "RIFF....WEBPVP8X", whose flags then say whether frames follow. // "RIFF....WEBPVP8X", whose flags then say whether frames follow.
const animated = mime === "image/webp" && head[15] === 0x58 && const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
(head[20] & 0x02) !== 0; (head[20] & 0x02) !== 0;
if (animated) return null; if (animatedWebp) return null;
return mime === "image/jpeg" return mime === "image/jpeg"
? { orientation: jpegOrientation(head), compat: "jpg" } ? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
: { orientation: 1, compat: "png" }; : { orientation: 1, animated: mime === "image/gif", compat: "png" };
} }
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */ /** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
@@ -195,7 +194,7 @@ async function scaledCopy(
const made = await ensureGenerated(scaledPath, async () => { const made = await ensureGenerated(scaledPath, async () => {
how ??= await describe(path); how ??= await describe(path);
if (!how) throw new Error("Not an image that can be scaled"); if (!how) throw new Error("Not an image that can be scaled");
await scaleImage(path, scaledPath, width, how.orientation, format); await scaleImage(path, scaledPath, width, how, format);
}, path); }, path);
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null; return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
} }

View File

@@ -111,8 +111,7 @@ export async function fetchProxiedImage(
const original = await fetchOriginal(url); const original = await fetchOriginal(url);
if (!original) return null; if (!original) return null;
const how = scalableImage(original.bytes); const how = scalableImage(original.bytes);
const bytes = how && const bytes = how && await scaleImageBytes(original.bytes, width, how);
await scaleImageBytes(original.bytes, width, how.orientation);
if (!bytes) return original; if (!bytes) return original;
writeImageCache(key, bytes, "image/webp"); writeImageCache(key, bytes, "image/webp");
return { bytes, contentType: "image/webp" }; return { bytes, contentType: "image/webp" };

View File

@@ -94,17 +94,27 @@ Deno.test("scalableImage: an animated WebP and what isn't an image are served as
assertEquals(scalableImage(webp(0x02)), null); assertEquals(scalableImage(webp(0x02)), null);
// A reader without WebP gets a PNG of what may be transparent, a JPEG of // A reader without WebP gets a PNG of what may be transparent, a JPEG of
// the rest. // the rest.
assertEquals(scalableImage(webp(0x10)), { orientation: 1, compat: "png" }); const still = { orientation: 1, animated: false };
assertEquals(scalableImage(webp(0x10)), { ...still, compat: "png" });
assertEquals( assertEquals(
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")), scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
{ orientation: 1, compat: "png" }, { ...still, compat: "png" },
); );
assertEquals(scalableImage(PNG), { orientation: 1, compat: "png" }); assertEquals(scalableImage(PNG), { ...still, compat: "png" });
assertEquals(scalableImage(jpegWithOrientation(1, true))?.compat, "jpg"); assertEquals(scalableImage(jpegWithOrientation(1, true)), {
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), { ...still,
orientation: 1,
compat: "jpg", compat: "jpg",
}); });
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
...still,
compat: "jpg",
});
// Only a GIF's frames are an animation to keep.
assertEquals(scalableImage(new TextEncoder().encode("GIF89a")), {
orientation: 1,
animated: true,
compat: "png",
});
assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null); assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null);
assertEquals(scalableImage(new Uint8Array()), null); assertEquals(scalableImage(new Uint8Array()), null);
}); });

View File

@@ -38,6 +38,24 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
); );
}); });
test("avatars: a still holding a second image is scaled to its first alone", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
// As a phone's photo carries a gain map after it, which ffmpeg reads as a
// second frame.
const photo = await makeImage(600, 400, "red");
const extra = await makeImage(150, 100, "white");
const both = new Uint8Array(photo.length + extra.length);
both.set(photo);
both.set(extra, photo.length);
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(both) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertFalse(new TextDecoder().decode(small.bytes).includes("ANMF"));
});
test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => { test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => {
if (!await hasFfmpeg()) return; if (!await hasFfmpeg()) return;
const user = await createUser(); const user = await createUser();