[web.worker] optimize Open Graph PNG size #118

Merged
eleith merged 1 commits from fix/112-optimize-opengraph-images into main 2026-10-16 23:31:00 +00:00
3 changed files with 75 additions and 33 deletions

No files matched your search

+5
View File
@@ -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.
@@ -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<string, string>(),
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([
@@ -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' }]
}