Add printable guest sign studio and refine event imagery

This commit is contained in:
2026-09-10 09:26:49 -04:00
parent 176b5fa95c
commit 3a115f1161
48 changed files with 1294 additions and 102 deletions
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { bannerExtract } from "./banner-crop";
import { bannerCropSchema } from "@album/contracts";
describe("banner crop", () => {
test("centers a legacy portrait in exact 8:3", () => {
expect(bannerExtract(1200, 2400)).toEqual({ left: 0, top: 975, width: 1200, height: 450 });
});
test("honors an off-center crop", () => {
expect(bannerExtract(4000, 3000, { x: 10, y: 20, width: 80, height: 40 })).toEqual({ left: 400, top: 600, width: 3200, height: 1200 });
});
test("keeps rounding inside original bounds", () => {
const crop = bannerExtract(1001, 751, { x: 0, y: 0, width: 100, height: 100 });
expect(crop.width / crop.height).toBe(8 / 3);
expect(crop.left + crop.width).toBeLessThanOrEqual(1001);
expect(crop.top + crop.height).toBeLessThanOrEqual(751);
});
test("rejects invalid and empty crops", () => {
expect(bannerCropSchema.safeParse({ x: 95, y: 0, width: 10, height: 20 }).success).toBe(false);
expect(() => bannerExtract(1000, 1000, { x: 0, y: 0, width: 0.01, height: 0.01 })).toThrow();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { bannerCropSchema, type BannerCrop } from "@album/contracts";
// Percent coordinates refer to the auto-oriented original, never the thumbnail.
export function bannerExtract(width: number, height: number, input?: BannerCrop | null) {
const crop = input ? bannerCropSchema.parse(input) : { x: 0, y: 0, width: 100, height: 100 };
const availableWidth = Math.min(width, width * crop.width / 100);
const availableHeight = Math.min(height, height * crop.height / 100);
// Whole multiples guarantee an exact 8:3 output, including small images.
const unit = Math.floor(Math.min(availableWidth / 8, availableHeight / 3));
if (unit < 1) throw new Error("Banner crop is too small");
const croppedWidth = unit * 8;
const croppedHeight = unit * 3;
return {
left: Math.max(0, Math.min(width - croppedWidth, Math.round(width * crop.x / 100 + (availableWidth - croppedWidth) / 2))),
top: Math.max(0, Math.min(height - croppedHeight, Math.round(height * crop.y / 100 + (availableHeight - croppedHeight) / 2))),
width: croppedWidth,
height: croppedHeight,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import sharp from "sharp";
import { bannerVariant, photoVariants, VARIANT_CONTENT_TYPE } from "./image-variants";
test("photo variants are bounded WebP files and leave original bytes untouched", async () => {
const original = await sharp({ create: { width: 3000, height: 1500, channels: 3, background: "#4878ab" } }).jpeg().toBuffer();
const before = Buffer.from(original);
const [display, thumb] = await photoVariants(sharp(original).rotate());
expect(VARIANT_CONTENT_TYPE).toBe("image/webp");
for (const [bytes, width, height] of [[display, 2048, 1024], [thumb, 400, 200]] as const) {
const metadata = await sharp(bytes).metadata();
expect(metadata.format).toBe("webp");
expect(metadata.width).toBe(width);
expect(metadata.height).toBe(height);
}
expect(original.equals(before)).toBe(true);
});
test("small images are not enlarged and transparency survives", async () => {
const image = sharp({ create: { width: 80, height: 120, channels: 4, background: { r: 20, g: 40, b: 60, alpha: 0.5 } } });
for (const bytes of await photoVariants(image)) {
const metadata = await sharp(bytes).metadata();
expect(metadata.width).toBe(80);
expect(metadata.height).toBe(120);
expect(metadata.hasAlpha).toBe(true);
}
});
test("banner generates an exact 8:3 WebP crop", async () => {
const bytes = await bannerVariant(sharp({ create: { width: 3200, height: 2400, channels: 3, background: "#4878ab" } }), { x: 0, y: 25, width: 100, height: 50 });
const metadata = await sharp(bytes).metadata();
expect(metadata.format).toBe("webp");
expect(metadata.width).toBe(2400);
expect(metadata.height).toBe(900);
});
test("auto-orientation is applied before sizing WebP variants", async () => {
const source = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#4878ab" } }).jpeg().withMetadata({ orientation: 6 }).toBuffer();
const [display] = await photoVariants(sharp(source).rotate());
const metadata = await sharp(display).metadata();
expect(metadata.width).toBe(80);
expect(metadata.height).toBe(120);
expect(metadata.orientation).toBeUndefined();
});
+22
View File
@@ -0,0 +1,22 @@
import sharp, { type Sharp } from "sharp";
import type { BannerCrop } from "@album/contracts";
import { bannerExtract } from "./banner-crop";
export const VARIANT_CONTENT_TYPE = "image/webp";
// Callers supply an auto-oriented image. Never modify the stored original.
export function photoVariants(image: Sharp) {
return Promise.all([
image.clone().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82, effort: 4 }).toBuffer(),
image.clone().resize({ width: 400, height: 400, fit: "inside", withoutEnlargement: true })
.webp({ quality: 75, effort: 4 }).toBuffer(),
]);
}
export async function bannerVariant(image: Sharp, crop?: BannerCrop | null) {
const { data, info } = await image.raw().toBuffer({ resolveWithObject: true });
return sharp(data, { raw: info }).extract(bannerExtract(info.width, info.height, crop))
.resize({ width: 2400, height: 900, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82, effort: 4 }).toBuffer();
}
+10 -30
View File
@@ -1,6 +1,8 @@
import { sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import sharp from "sharp";
import { bannerVariant, photoVariants, VARIANT_CONTENT_TYPE } from "./image-variants";
import type { BannerCrop } from "@album/contracts";
import { processPhotoExport } from "./exports";
import { processEmailDelivery } from "@album/email/queue";
import convert from "heic-convert";
@@ -84,31 +86,10 @@ async function processJob(job: ClaimedJob) {
const metadata = await image.metadata();
const displayKey = displayObjectKey(photo.eventId, photo.id);
const thumbKey = thumbObjectKey(photo.eventId, photo.id);
const [display, thumb] = await Promise.all([
image
.clone()
.resize({
width: 2048,
height: 2048,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer(),
image
.clone()
.resize({
width: 400,
height: 400,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 75, mozjpeg: true })
.toBuffer(),
]);
const [display, thumb] = await photoVariants(image);
await Promise.all([
putObject({ key: displayKey, body: display, contentType: "image/jpeg" }),
putObject({ key: thumbKey, body: thumb, contentType: "image/jpeg" }),
putObject({ key: displayKey, body: display, contentType: VARIANT_CONTENT_TYPE }),
putObject({ key: thumbKey, body: thumb, contentType: VARIANT_CONTENT_TYPE }),
]);
await db.transaction(async (tx) => {
await tx
@@ -176,18 +157,17 @@ async function processBanner() {
SELECT id FROM event_banners
WHERE status = 'pending' OR (status = 'processing' AND updated_at < now() - interval '10 minutes')
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING id, event_id, original_key, content_type
) RETURNING id, event_id, original_key, content_type, crop
`);
const banner = (rows as unknown as { id: string; event_id: string; original_key: string; content_type: string }[])[0];
const banner = (rows as unknown as { id: string; event_id: string; original_key: string; content_type: string; crop: BannerCrop | null }[])[0];
if (!banner) return false;
const scope = and(eq(eventBanners.id, banner.id), eq(eventBanners.eventId, banner.event_id));
try {
const original = await getObjectBuffer(banner.original_key);
const image = await decodeImage(original, banner.content_type);
const display = await image.resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82, mozjpeg: true }).toBuffer();
const displayKey = `events/${banner.event_id}/banners/${banner.id}/display.jpg`;
await putObject({ key: displayKey, body: display, contentType: "image/jpeg" });
const display = await bannerVariant(image, banner.crop);
const displayKey = `events/${banner.event_id}/banners/${banner.id}/display.webp`;
await putObject({ key: displayKey, body: display, contentType: VARIANT_CONTENT_TYPE });
await getDb().update(eventBanners).set({ status: "ready", displayKey, updatedAt: new Date() }).where(scope);
console.info(`Banner processed for event ${banner.event_id}`);
} catch {