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: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,6 @@ jobs:
node-version: 22
cache: npm

- name: Install screenshot tools
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends imagemagick fonts-dejavu-core

- name: Resolve Chrome
run: |
set -euo pipefail
Expand Down
11 changes: 0 additions & 11 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,6 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Install screenshot tools
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends imagemagick fonts-dejavu-core

- name: Resolve Chrome
run: |
set -euo pipefail
Expand All @@ -55,9 +49,4 @@ jobs:
run: |
set -euo pipefail
node --version
if command -v magick >/dev/null 2>&1; then
magick --version | head -n 1
else
convert --version | head -n 1
fi
"$CHROME_BIN" --version
15 changes: 0 additions & 15 deletions .github/workflows/windows-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,6 @@ jobs:
}
& $chrome --version

- name: Install ImageMagick
shell: pwsh
run: |
if (-not (Get-Command magick.exe -ErrorAction SilentlyContinue)) {
choco install imagemagick --yes --no-progress
$magick = Get-ChildItem $env:ProgramFiles -Directory -Filter 'ImageMagick*' |
Select-Object -First 1
if (-not $magick) {
throw 'ImageMagick installed but magick.exe was not found.'
}
$env:PATH = "$($magick.FullName);$env:PATH"
$magick.FullName | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
}
magick -version

- name: Install dependencies
run: npm ci

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ application services to set up.

Run `npm run verify` to check the project, or `npm run build` to make its
publishable files. Full verification works on Windows and Linux and needs
Chrome or Chromium plus ImageMagick; setup details are in the
Chrome or Chromium; setup details are in the
[code guide](docs/code.md#local-and-ci-environments). GitHub also runs the
checks for you when you make a change. The normal checks run on Linux even if
you are working on Windows; maintainers can manually run Windows verification to
Expand Down
16 changes: 7 additions & 9 deletions docs/code.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,21 @@ second catalogue here.
## Local and CI environments

The project commands work from Windows PowerShell or a Linux shell. Full local
verification needs Chrome and ImageMagick on Windows, or Chromium and
ImageMagick on Linux. GitHub Actions runs the same verification on Ubuntu.
verification needs Chrome on Windows or Chromium on Linux. GitHub Actions runs
the same verification on Ubuntu.

On Debian or Ubuntu, install the screenshot tools with:
On Debian or Ubuntu, install Chromium with:

```sh
sudo apt-get install chromium imagemagick
sudo apt-get install chromium
```

On Windows, install Google Chrome and ImageMagick, and make sure ImageMagick's
`magick.exe` is on `PATH`. Chrome is found in its standard installation
folders; set these PowerShell variables if either tool is installed elsewhere:
replace the example paths with the actual locations on your computer.
On Windows, install Google Chrome. Chrome is found in its standard installation
folders; set this PowerShell variable if it is installed elsewhere. Replace
the example path with its actual location on your computer.

```powershell
$env:CHROME_BIN = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
$env:IMAGEMAGICK_BIN = 'C:\Program Files\ImageMagick\magick.exe'
npm run verify
```

Expand Down
241 changes: 138 additions & 103 deletions scripts/shot-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@
* nothing in CI can do that looking on the reviewer's behalf - see
* docs/decisions/Let the author attach the screenshots.md.
*/
import { existsSync, readdirSync, rmSync } from "node:fs";
import { createServer } from "node:http";
import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { rm } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

import { magick, haveImageMagick } from "./tools.mjs";
import { openChrome } from "./chrome.mjs";
import { freeDebugPort, listenOnFreePort } from "./concurrency.mjs";

const root = fileURLToPath(new URL("..", import.meta.url));
const shotsDir = join(root, ".art/shots");
Expand All @@ -25,132 +29,163 @@ const SHEET = "contact-sheet.png";
const CELL_HEIGHT = 300;
const COLUMNS = 4;

function escapeHtml(text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

/**
* Whether ImageMagick can render text. Ubuntu's imagemagick package installed
* without recommends has no font, and `label:` then fails with "unable to read
* font (null)" - which is how the GitHub runner is set up. Probing once beats
* naming a font, since which fonts exist is exactly what varies.
* The browser handles sizing, labels, and image composition in one render.
* Grouping rows explicitly keeps the prior four-column, left-to-right order.
*/
let labelsWork;
function canLabel() {
if (labelsWork === undefined) {
const probe = join(shotsDir, "_probe.png");
export function createSheetHtml(shots) {
const rows = [];
const sortedShots = [...shots].sort();
for (let i = 0; i < sortedShots.length; i += COLUMNS) {
const cells = sortedShots
.slice(i, i + COLUMNS)
.map((file) => {
const label = escapeHtml(file.replace(/\.png$/, ""));
return `<figure><figcaption>${label}</figcaption><img src="/shots/${encodeURIComponent(file)}" alt="${label}"></figure>`;
})
.join("");
rows.push(`<div class="row">${cells}</div>`);
}
return `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Screenshot contact sheet</title>
<style>
html, body { margin: 0; background: white; }
.sheet { display: flex; flex-direction: column; align-items: flex-start; width: max-content; }
.row { display: flex; align-items: flex-end; }
figure { display: flex; flex: none; flex-direction: column; align-items: center; margin: 0; padding: 8px; }
figcaption { color: #333; font: 18px sans-serif; white-space: nowrap; }
img { display: block; height: ${CELL_HEIGHT}px; width: auto; }
</style>
<main class="sheet">${rows.join("\n")}</main>
</html>`;
}

function serveSheet(shots, html) {
const files = new Set(shots);
return createServer((request, response) => {
let pathname;
try {
magick(["-pointsize", "18", "label:probe", probe]);
labelsWork = true;
pathname = new URL(request.url, "http://localhost").pathname;
} catch {
labelsWork = false;
console.warn("ImageMagick has no font available; the contact sheet will not be labelled.");
response.writeHead(400).end("invalid URL");
return;
}

if (pathname === "/") {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(html);
return;
}

let filename;
try {
filename = decodeURIComponent(pathname.slice("/shots/".length));
} catch {
response.writeHead(400).end("invalid filename");
return;
}
if (!pathname.startsWith("/shots/") || !files.has(filename)) {
response.writeHead(404).end("not found");
return;
}

response
.writeHead(200, { "content-type": "image/png", "cache-control": "no-store" })
.end(readFileSync(join(shotsDir, filename)));
});
}

async function waitForImages(browser, expectedCount) {
const deadline = Date.now() + 10000;
for (;;) {
if (
await browser.evaluate(
`document.readyState === 'complete' && document.images.length === ${expectedCount} && [...document.images].every(image => image.complete && image.naturalWidth > 0)`,
)
) {
await browser.evaluate("document.fonts.ready.then(() => true)");
return;
}
if (Date.now() >= deadline) {
throw new Error("Chrome timed out loading the contact sheet images.");
}
rmSync(probe, { force: true });
await sleep(50);
}
return labelsWork;
}

/**
* Builds the sheet and returns its path, or null if there was nothing to pack
* or ImageMagick is not installed. Throws if ImageMagick fails.
*
* Falls back to an unlabelled sheet where text cannot be rendered: the pictures
* are the point, and they stay in filename order, left to right and top to
* bottom.
*/
export function buildSheet() {
if (!existsSync(shotsDir) || !haveImageMagick()) return null;
export async function buildSheet() {
Comment thread
jbj marked this conversation as resolved.
Resolved
if (!existsSync(shotsDir)) return null;
const shots = readdirSync(shotsDir)
.filter((f) => f.endsWith(".png") && f !== SHEET)
.filter((file) => file.endsWith(".png") && file !== SHEET)
.sort();
if (shots.length === 0) return null;

const temporaries = [];
const server = serveSheet(shots, createSheetHtml(shots));
const profile = mkdtempSync(join(root, ".art/chrome-profile-sheet-"));
let browser;
let serverStarted = false;
try {
const label = canLabel();
const cells = shots.map((file) => {
const resized = join(shotsDir, `_fit-${file}`);
const cell = join(shotsDir, `_cell-${file}`);
temporaries.push(resized, cell);
// Portrait and landscape shots come out of the run at different sizes, so
// normalise on height and let the widths differ. The resize has to happen
// before the label joins the list, or the label is scaled to 300px tall too.
magick([
join(shotsDir, file),
"-resize",
`x${CELL_HEIGHT}`,
"-depth",
"8",
"-strip",
resized,
]);
magick([
"-background",
"white",
...(label
? ["-fill", "#333", "-pointsize", "18", `label:${file.replace(/\.png$/, "")}`]
: []),
resized,
"-gravity",
"center",
"-append",
"-bordercolor",
"white",
"-border",
"8",
cell,
]);
return cell;
const port = await listenOnFreePort(server);
serverStarted = true;
browser = await openChrome({ debugPort: freeDebugPort(), profileDir: profile });
await browser.send("Page.enable");
const navigation = await browser.send("Page.navigate", {
url: `http://127.0.0.1:${port}/`,
});
if (navigation.errorText) throw new Error(navigation.errorText);
await waitForImages(browser, shots.length);

const rows = [];
for (let i = 0; i < cells.length; i += COLUMNS) {
const row = join(shotsDir, `_row-${rows.length}.png`);
temporaries.push(row);
magick([
...cells.slice(i, i + COLUMNS),
"-background",
"white",
"-gravity",
"south",
"+append",
row,
]);
rows.push(row);
}

const { width, height } = await browser.evaluate(
"(() => { const { width, height } = document.querySelector('.sheet').getBoundingClientRect(); return { width: Math.ceil(width), height: Math.ceil(height) }; })()",
);
await browser.send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: false,
});
const { data } = await browser.send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: true,
clip: { x: 0, y: 0, width, height, scale: 1 },
});
const sheet = join(shotsDir, SHEET);
magick([
...rows,
"-background",
"white",
"-gravity",
"west",
"-append",
"-depth",
"8",
"-strip",
sheet,
]);
writeFileSync(sheet, Buffer.from(data, "base64"));
return sheet;
} finally {
// Cleared even when a step failed, or the leftovers are picked up as
// screenshots by the next run and uploaded as if they were evidence.
for (const file of temporaries) rmSync(file, { force: true });
try {
await browser?.close();
} finally {
try {
if (serverStarted) {
server.closeAllConnections();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
} finally {
await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (!haveImageMagick()) {
console.error(
"Cannot find ImageMagick.\n" +
"On Debian or Ubuntu: sudo apt-get install imagemagick.\n" +
"On Windows, install ImageMagick and make sure magick.exe is on PATH.",
);
process.exit(1);
}
let sheet;
try {
sheet = buildSheet();
sheet = await buildSheet();
} catch (error) {
console.error(`ImageMagick failed: ${error.message.split("\n")[0]}`);
console.error(`Chrome could not build the contact sheet: ${error.message.split("\n")[0]}`);
process.exit(1);
}
if (!sheet) {
Expand Down
Loading
Loading