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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user