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. */
|
||||
const ENCODERS: Record<ScaledFormat, string[]> = {
|
||||
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"],
|
||||
jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
|
||||
png: ["-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
|
||||
* 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,
|
||||
{ orientation, animated }: ImageTraits,
|
||||
format: ScaledFormat = "webp",
|
||||
): Promise<void> {
|
||||
const upright = ORIENTATION_FILTERS[orientation];
|
||||
// A JPEG's colour planes halve its height, which they need even.
|
||||
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([
|
||||
"-noautorotate",
|
||||
"-format_whitelist",
|
||||
@@ -231,6 +247,7 @@ export function scaleImage(
|
||||
"-vf",
|
||||
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
|
||||
"-an",
|
||||
...frames,
|
||||
...ENCODERS[format],
|
||||
], outPath);
|
||||
}
|
||||
@@ -243,7 +260,7 @@ export function scaleImage(
|
||||
export async function scaleImageBytes(
|
||||
bytes: Uint8Array,
|
||||
width: ImageWidth,
|
||||
orientation = 1,
|
||||
traits: ImageTraits,
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!await ffmpegAvailable()) return null;
|
||||
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
|
||||
@@ -251,7 +268,7 @@ export async function scaleImageBytes(
|
||||
try {
|
||||
await Deno.mkdir(dirname(srcPath), { recursive: true });
|
||||
await Deno.writeFile(srcPath, bytes);
|
||||
await scaleImage(srcPath, outPath, width, orientation);
|
||||
await scaleImage(srcPath, outPath, width, traits);
|
||||
return await Deno.readFile(outPath);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user