Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions scripts/changelog/imagegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ export type RunResult = {
export type RunDependencies = {
/** Seam for tests, so the pipeline can be exercised without calling OpenAI. */
generate?: typeof generateBackground;
/** Seam for tests, so renders can share one browser instead of launching each time. */
render?: typeof renderCard;
};

/** Runs the full pipeline for the given options. */
export async function run(
options: Options,
log: (message: string) => void = () => {},
{ generate = generateBackground }: RunDependencies = {},
{ generate = generateBackground, render = renderCard }: RunDependencies = {},
): Promise<RunResult> {
const spec = options.motif
? buildImageSpec({ motif: options.motif, colorGrade: options.colorGrade })
Expand All @@ -54,7 +56,7 @@ export async function run(
const background = await prepareBackground(source, { palette: options.palette });

log('Rendering card…');
const png = await renderCard(
const png = await render(
{
categoryLines: splitLabelLines(options.category),
dateLines: formatCardDate(options.date),
Expand Down
23 changes: 15 additions & 8 deletions scripts/changelog/imagegen/render.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
// ABOUTME: Renders the card HTML to a PNG buffer with headless Chromium.
// ABOUTME: The viewport is the Figma frame; --scale multiplies the exported resolution.
import { chromium } from 'playwright';
import { type Browser, chromium } from 'playwright';

import { CARD_HEIGHT, CARD_WIDTH, type CardContent, renderCardHtml } from './template';

export type RenderOptions = {
/** Resolution multiplier: 2 exports a 2840x1600 PNG. */
scale?: number;
/** An already-launched browser to render in; the caller owns its lifecycle. */
browser?: Browser;
};

/** Renders a card to PNG bytes. */
export async function renderCard(
content: CardContent,
{ scale = 2 }: RenderOptions = {},
{ scale = 2, browser }: RenderOptions = {},
): Promise<Buffer> {
const browser = await chromium.launch();
const ownBrowser = browser === undefined;
const target = browser ?? (await chromium.launch());
try {
const page = await browser.newPage({
const page = await target.newPage({
viewport: { width: CARD_WIDTH, height: CARD_HEIGHT },
deviceScaleFactor: scale,
});
await page.setContent(renderCardHtml(content), { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
return await page.screenshot({ type: 'png' });
try {
await page.setContent(renderCardHtml(content), { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
return await page.screenshot({ type: 'png' });
} finally {
await page.close();
}
} finally {
await browser.close();
if (ownBrowser) await target.close();
}
}
15 changes: 15 additions & 0 deletions tests/imagegen/helpers/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// ABOUTME: Shares one headless Chromium across all imagegen test files, because repeated
// ABOUTME: chromium.launch() calls in the same Bun test process can deadlock on spawn.
import { type Browser, chromium } from 'playwright';

let shared: Promise<Browser> | undefined;

/**
* Launches Chromium once per test process and reuses it. Never closed here:
* Playwright kills the browser when the test process exits, and an afterAll
* in one file must not tear it down under another file's tests.
*/
export function getSharedBrowser(): Promise<Browser> {
shared ??= chromium.launch();
return shared;
}
7 changes: 4 additions & 3 deletions tests/imagegen/integration/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { beforeAll, describe, setDefaultTimeout, test } from 'bun:test';
import assert from 'node:assert/strict';
import { PNG } from 'pngjs';

// The render hook launches headless Chromium, which is slow enough on a loaded
// two-core CI runner to blow past a 120s budget.
// Renders share one Chromium via getSharedBrowser, but the process-wide first
// launch can land in this file and needs generous headroom on a loaded runner.
setDefaultTimeout(240000);

import { renderCard } from '../../../scripts/changelog/imagegen/render';
import { CARD_HEIGHT, CARD_WIDTH } from '../../../scripts/changelog/imagegen/template';
import { getSharedBrowser } from '../helpers/browser';

/** A flat mid-grey background, so overlays are easy to detect. */
function solidBackgroundDataUri(r: number, g: number, b: number): string {
Expand All @@ -35,7 +36,7 @@ describe('renderCard', () => {
const scale = 1;

beforeAll(async () => {
image = PNG.sync.read(await renderCard(content, { scale }));
image = PNG.sync.read(await renderCard(content, { scale, browser: await getSharedBrowser() }));
});

const brightness = (x: number, y: number) => {
Expand Down
16 changes: 11 additions & 5 deletions tests/imagegen/integration/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { afterAll, beforeAll, describe, setDefaultTimeout, test } from 'bun:test
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';

// The pipeline launches a fresh headless Chromium per render. On a loaded
// two-core CI runner one launch has taken over 120s, so allow generous headroom.
// Renders share one Chromium via getSharedBrowser, but the process-wide first
// launch can land in this file and needs generous headroom on a loaded runner.
setDefaultTimeout(240000);

import { tmpdir } from 'node:os';
Expand All @@ -15,6 +15,12 @@ import sharp from 'sharp';
import { run } from '../../../scripts/changelog/imagegen/index';
import { parseOptions } from '../../../scripts/changelog/imagegen/options';
import { getPalette } from '../../../scripts/changelog/imagegen/palettes';
import { renderCard } from '../../../scripts/changelog/imagegen/render';
import { getSharedBrowser } from '../helpers/browser';

/** Renders in the process-wide shared browser instead of launching one per card. */
const render: typeof renderCard = async (content, options) =>
renderCard(content, { ...options, browser: await getSharedBrowser() });

const MOTIF = 'A quiet harbour at dawn where five ships dock at one long pier.';

Expand Down Expand Up @@ -67,7 +73,7 @@ describe('run (generate path)', () => {
'1',
]);

const result = await run(options, () => {}, { generate: fakeGenerator(calls) });
const result = await run(options, () => {}, { generate: fakeGenerator(calls), render });

assert.equal(calls.length, 1);
assert.equal(calls[0]!.size, '1536x864');
Expand Down Expand Up @@ -98,7 +104,7 @@ describe('run (generate path)', () => {
'1',
]);

const result = await run(options, () => {}, { generate: fakeGenerator([]) });
const result = await run(options, () => {}, { generate: fakeGenerator([]), render });

assert.equal(result.source, join(workdir, 'kept-source.png'));
const { width, height } = await sharp(await readFile(result.source!)).metadata();
Expand All @@ -121,7 +127,7 @@ describe('run (generate path)', () => {
'1',
]);

await run(options, () => {}, { generate: fakeGenerator([]) });
await run(options, () => {}, { generate: fakeGenerator([]), render });

// Re-dithering the kept original must reproduce exactly the palette in use.
const palette = getPalette('Ocean');
Expand Down
Loading