From ac811d618bf1f1f7060c64c5e84a172dbc768d54 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 16:28:42 -0700 Subject: [PATCH] [web.worker] optimize Open Graph PNG encoding --- .changeset/optimize-opengraph-png.md | 5 + .../worker/src/transforms/opengraph.test.ts | 96 ++++++++++++------- .../web/worker/src/transforms/opengraph.ts | 7 +- 3 files changed, 75 insertions(+), 33 deletions(-) create mode 100644 .changeset/optimize-opengraph-png.md diff --git a/.changeset/optimize-opengraph-png.md b/.changeset/optimize-opengraph-png.md new file mode 100644 index 00000000..12e2dbbd --- /dev/null +++ b/.changeset/optimize-opengraph-png.md @@ -0,0 +1,5 @@ +--- +"@luzzle/web.worker": patch +--- + +Reduce generated Open Graph PNG sizes using the existing Sharp dependency's palette encoder. Keep the original whenever encoding would not reduce file size, and preserve image paths and MIME types. Palette encoding trades some color fidelity for smaller previews. diff --git a/packages/web/worker/src/transforms/opengraph.test.ts b/packages/web/worker/src/transforms/opengraph.test.ts index afc06775..2ebb574d 100644 --- a/packages/web/worker/src/transforms/opengraph.test.ts +++ b/packages/web/worker/src/transforms/opengraph.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' -import { mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import path from 'node:path' +import sharp from 'sharp' import { run } from './opengraph.js' import { getOpenGraphPath } from '../assets/paths.js' import { renderOpengraphPng } from '../pieces/render.js' @@ -10,6 +11,7 @@ import type { Pieces } from '@luzzle/core' import { makeLogger } from '../../test/logger.js' const TEMP_DIR = path.join(import.meta.dirname, 'temp-opengraph-transform-fixtures') +let renderedPng: Buffer vi.mock('../pieces/render.js', () => ({ renderOpengraphPng: vi.fn(), @@ -41,8 +43,25 @@ const makeConfig = (): Config => url: { app: 'http://localhost' }, }) as unknown as Config -beforeAll(() => { +const makeInput = () => ({ + webPiece: makeWebPiece(), + config: makeConfig(), + outDir: TEMP_DIR, + pieces: {} as Pieces, + assetKeyToPath: new Map(), + logger: makeLogger(), +}) + +beforeAll(async () => { mkdirSync(TEMP_DIR, { recursive: true }) + renderedPng = await sharp({ + create: { + width: 1200, + height: 630, + channels: 4, + background: { r: 30, g: 60, b: 90, alpha: 0.5 }, + }, + }).png({ compressionLevel: 0 }).toBuffer() }) afterAll(() => { @@ -54,21 +73,23 @@ afterEach(() => { }) describe('transforms/opengraph', () => { - test('generates opengraph image and returns asset record', async () => { - const mockPieces = {} as unknown as Pieces - + test('writes a smaller PNG preserving dimensions and transparency, and returns its asset record', async () => { + const input = makeInput() mocks.getOpenGraphPath.mockReturnValue('books/key/opengraph.png') - mocks.renderOpengraphPng.mockResolvedValue(Buffer.from('png-data')) + mocks.renderOpengraphPng.mockResolvedValue(renderedPng) - const records = await run({ - webPiece: makeWebPiece(), - config: makeConfig(), - outDir: TEMP_DIR, - pieces: mockPieces, - assetKeyToPath: new Map(), - logger: makeLogger(), - }) + const records = await run(input) + const output = readFileSync(path.join(TEMP_DIR, 'books/key/opengraph.png')) + expect(output.length).toBeLessThan(renderedPng.length) + expect(await sharp(output).metadata()).toEqual(expect.objectContaining({ + format: 'png', + width: 1200, + height: 630, + hasAlpha: true, + })) + const { data, info } = await sharp(output).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) + expect(data[info.channels - 1]).toBe(128) expect(mocks.getOpenGraphPath).toHaveBeenCalledWith('books', 'key') expect(mocks.renderOpengraphPng).toHaveBeenCalledOnce() expect(records).toEqual([ @@ -80,37 +101,48 @@ describe('transforms/opengraph', () => { ]) }) - test('passes priorAssets to renderOpengraphPng', async () => { - const mockPieces = {} as unknown as Pieces + test('keeps the original PNG when palette encoding would make it larger', async () => { + const original = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' + ) + mocks.getOpenGraphPath.mockReturnValue('books/tiny/opengraph.png') + mocks.renderOpengraphPng.mockResolvedValue(original) + await run(makeInput()) + + expect(readFileSync(path.join(TEMP_DIR, 'books/tiny/opengraph.png'))).toEqual(original) + }) + + test('propagates encoding errors without writing an output', async () => { + mocks.getOpenGraphPath.mockReturnValue('books/invalid/opengraph.png') + mocks.renderOpengraphPng.mockResolvedValue(Buffer.from('invalid PNG')) + + await expect(run(makeInput())).rejects.toThrow() + + expect(existsSync(path.join(TEMP_DIR, 'books/invalid/opengraph.png'))).toBe(false) + }) + + test('passes priorAssets to renderOpengraphPng', async () => { mocks.getOpenGraphPath.mockReturnValue('books/key/opengraph.png') - mocks.renderOpengraphPng.mockResolvedValue(Buffer.from('png-data')) + mocks.renderOpengraphPng.mockResolvedValue(renderedPng) const priorAssets = [ { asset_key: 'img-key', transformation: 'image.original', asset_path: 'books/k1/cover.png', - mime_type: 'image/png' - } + mime_type: 'image/png', + }, ] - const records = await run({ - webPiece: makeWebPiece(), - config: makeConfig(), - outDir: TEMP_DIR, - pieces: mockPieces, - assetKeyToPath: new Map(), - logger: makeLogger(), - priorAssets, - }) + const input = makeInput() + const records = await run({ ...input, priorAssets }) expect(mocks.renderOpengraphPng).toHaveBeenCalledWith( - expect.objectContaining({ - assets: priorAssets - }), + expect.objectContaining({ assets: priorAssets }), priorAssets, - expect.anything(), + input.config, TEMP_DIR ) expect(records).toEqual([ diff --git a/packages/web/worker/src/transforms/opengraph.ts b/packages/web/worker/src/transforms/opengraph.ts index b3df86c0..de259db2 100644 --- a/packages/web/worker/src/transforms/opengraph.ts +++ b/packages/web/worker/src/transforms/opengraph.ts @@ -1,5 +1,6 @@ import path from 'node:path' import { promises as fs } from 'node:fs' +import sharp from 'sharp' import { getOpenGraphPath } from '../assets/paths.js' import { renderOpengraphPng } from '../pieces/render.js' import type { TransformInput, AssetRecord } from './utils/types.js' @@ -36,9 +37,13 @@ export async function run({ } const buffer = await renderOpengraphPng(publicPiece, assets, config, outDir) + const optimized = await sharp(buffer) + .png({ palette: true, quality: 90, effort: 7, compressionLevel: 9 }) + .toBuffer() + const output = optimized.length < buffer.length ? optimized : buffer await fs.mkdir(path.dirname(outputPath), { recursive: true }) - await fs.writeFile(outputPath, buffer) + await fs.writeFile(outputPath, output) return [{ transformation: 'opengraph', asset_path: ogPath, mime_type: 'image/png' }] } -- 2.54.0