Add printable guest sign studio and refine event imagery
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user