feat: generate multiple thumbs and posters

This commit is contained in:
William Valentin
2026-02-01 14:01:32 -08:00
parent 517e21d0b7
commit d6e6f275b7
3 changed files with 95 additions and 76 deletions

View File

@@ -0,0 +1,9 @@
import { test, expect } from "bun:test";
import { computeImageVariantPlan } from "../variants";
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
expect(computeImageVariantPlan()).toEqual([
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
]);
});

View File

@@ -7,6 +7,8 @@ import { Readable } from "stream";
import sharp from "sharp"; import sharp from "sharp";
import { computeImageVariantPlan, computeVideoPosterPlan } from "./variants";
import { import {
CopyObjectCommand, CopyObjectCommand,
GetObjectCommand, GetObjectCommand,
@@ -426,53 +428,37 @@ export async function handleProcessAsset(raw: unknown) {
if (updates.width === null && imgMeta.width) updates.width = imgMeta.width; if (updates.width === null && imgMeta.width) updates.width = imgMeta.width;
if (updates.height === null && imgMeta.height) updates.height = imgMeta.height; if (updates.height === null && imgMeta.height) updates.height = imgMeta.height;
const thumb256Path = join(tempDir, "thumb_256.jpg"); const imagePlan = computeImageVariantPlan();
const thumb768Path = join(tempDir, "thumb_768.jpg"); const thumbKeys: Record<number, string> = {};
await sharp(inputPath) for (const item of imagePlan) {
.rotate() const size = item.size;
.resize(256, 256, { fit: "inside", withoutEnlargement: true }) const thumbPath = join(tempDir, `thumb_${size}.jpg`);
.jpeg({ quality: 80 }) await sharp(inputPath)
.toFile(thumb256Path); .rotate()
await sharp(inputPath) .resize(size, size, { fit: "inside", withoutEnlargement: true })
.rotate() .jpeg({ quality: 80 })
.resize(768, 768, { fit: "inside", withoutEnlargement: true }) .toFile(thumbPath);
.jpeg({ quality: 80 })
.toFile(thumb768Path);
const thumb256Key = `thumbs/${asset.id}/image_256.jpg`; const thumbKey = `thumbs/${asset.id}/image_${size}.jpg`;
const thumb768Key = `thumbs/${asset.id}/image_768.jpg`; await uploadObject({
await uploadObject({ bucket: asset.bucket,
bucket: asset.bucket, key: thumbKey,
key: thumb256Key, filePath: thumbPath,
filePath: thumb256Path, contentType: "image/jpeg",
contentType: "image/jpeg", });
}); await upsertVariant({
await uploadObject({ assetId: asset.id,
bucket: asset.bucket, kind: "thumb",
key: thumb768Key, size,
filePath: thumb768Path, key: thumbKey,
contentType: "image/jpeg", mimeType: "image/jpeg",
}); width: typeof updates.width === "number" ? updates.width : null,
await upsertVariant({ height: typeof updates.height === "number" ? updates.height : null,
assetId: asset.id, });
kind: "thumb", thumbKeys[size] = thumbKey;
size: 256, }
key: thumb256Key, updates.thumb_small_key = thumbKeys[256] ?? null;
mimeType: "image/jpeg", updates.thumb_med_key = thumbKeys[768] ?? null;
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
await upsertVariant({
assetId: asset.id,
kind: "thumb",
size: 768,
key: thumb768Key,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
updates.thumb_small_key = thumb256Key;
updates.thumb_med_key = thumb768Key;
} else if (asset.media_type === "video") { } else if (asset.media_type === "video") {
rawTags = await tryReadExifTags(); rawTags = await tryReadExifTags();
maybeSetCaptureDateFromTags(rawTags); maybeSetCaptureDateFromTags(rawTags);
@@ -512,36 +498,42 @@ export async function handleProcessAsset(raw: unknown) {
rawTags = { ...rawTags, ffprobe: ffprobeData }; rawTags = { ...rawTags, ffprobe: ffprobeData };
const posterPath = join(tempDir, "poster_256.jpg"); const posterPlan = computeVideoPosterPlan();
await runCommand("ffmpeg", [ const posterKeys: Record<number, string> = {};
"-i", for (const item of posterPlan) {
inputPath, const size = item.size;
"-vf", const posterPath = join(tempDir, `poster_${size}.jpg`);
"scale=256:256:force_original_aspect_ratio=decrease", await runCommand("ffmpeg", [
"-vframes", "-i",
"1", inputPath,
"-q:v", "-vf",
"2", `scale=${size}:${size}:force_original_aspect_ratio=decrease`,
"-y", "-vframes",
posterPath "1",
]); "-q:v",
const posterKey = `thumbs/${asset.id}/poster_256.jpg`; "2",
await uploadObject({ "-y",
bucket: asset.bucket, posterPath
key: posterKey, ]);
filePath: posterPath, const posterKey = `thumbs/${asset.id}/poster_${size}.jpg`;
contentType: "image/jpeg", await uploadObject({
}); bucket: asset.bucket,
await upsertVariant({ key: posterKey,
assetId: asset.id, filePath: posterPath,
kind: "poster", contentType: "image/jpeg",
size: 256, });
key: posterKey, await upsertVariant({
mimeType: "image/jpeg", assetId: asset.id,
width: typeof updates.width === "number" ? updates.width : null, kind: "poster",
height: typeof updates.height === "number" ? updates.height : null, size,
}); key: posterKey,
updates.poster_key = posterKey; mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
posterKeys[size] = posterKey;
}
updates.poster_key = posterKeys[256] ?? null;
} }
if (asset.media_type === "video" && typeof updates.poster_key !== "string") { if (asset.media_type === "video" && typeof updates.poster_key !== "string") {

View File

@@ -0,0 +1,18 @@
export type VariantPlanItem = {
kind: "thumb" | "poster";
size: number;
};
export function computeImageVariantPlan(): VariantPlanItem[] {
return [
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
];
}
export function computeVideoPosterPlan(): VariantPlanItem[] {
return [
{ kind: "poster", size: 256 },
{ kind: "poster", size: 768 },
];
}