DEV Community

Daniel Pertu
Daniel Pertu

Posted on

cwebp crops our 372 card screenshots because sips ignores --cropOffset, and sips pads them because cwebp cannot

Every practice game on CogniPrep has a card on its provider page, and every card has a screenshot of that game in play at the top of it. There are 372 of them. The McKinsey Solve page has six, Arctic Shores has fourteen.

They are not captured by a headless browser in CI. They are captured by hand, because a screenshot of a game is only useful if it was taken at the moment the game looks like itself, and no script knows when that is. What is automated is everything after the capture: one tsx script, two command line tools, and a generated manifest.

The capture side is a route that only exists in development

The games need a signed in user with access. A screenshot run that has to log in and buy a provider is a screenshot run nobody does twice, so there is a preview route instead:

// app/dev/game-preview/[game-name]/page.tsx
export const metadata: Metadata = { robots: { index: false, follow: false } };

export default async function GamePreviewPage({ params }) {
  if (process.env.NODE_ENV !== 'development') notFound();
  // ...renders the game
}
Enter fullscreen mode Exit fullscreen mode

next dev only. Every other build returns a 404 before it looks at the slug, and the route carries noindex as well, because belt and braces costs one line.

The capture itself is a browser tab resized to 1280 by 800 at device pixel ratio 2, so the PNG that lands on disk is 2560 by 1600.

The script refuses to guess the scale

Everything downstream is arithmetic in CSS pixels: the header is 64 pixels tall, the crop is 16:10 about the centre of what is left. The PNG is in device pixels. So the first thing the script does is work out the scale and then check its own assumption:

const size = pngSize(file);
const scale = size.width / viewport.width;
if (Math.abs(size.height / scale - viewport.height) > 1) {
  throw new Error(
    `${file} is ${size.width}x${size.height}; resize the tab to ${viewport.width}x${viewport.height} and capture again.`
  );
}
Enter fullscreen mode Exit fullscreen mode

A capture at the wrong window height still has the right width, so the scale comes out right and only the aspect is wrong. Without that check the output is a silently mis-cropped card, which is the kind of bug you find three weeks later on a phone. With it, the failure is a sentence telling you what to do.

The same applies to a hand chosen crop box. Pass three numbers and they are validated against the play area:

Crop 40,700 1200x750 leaves the play area (y 64 to 800, x 0 to 1280).
Enter fullscreen mode Exit fullscreen mode

Two tools, because each one is missing half the job

Reading the PNG's dimensions, converting formats and padding is sips, which ships with macOS. Encoding is cwebp. The division of labour is not a preference, it is what the tools can do.

cwebp crops. It takes -crop x y w h in pixels, applies it before encoding, and with -lossless it is a clean cut:

execFileSync('cwebp', [
  '-quiet', '-q', '80',
  '-crop', px(x), px(y), px(width), px(height),
  '-resize', '960', '0',
  source, '-o', out,
]);
Enter fullscreen mode Exit fullscreen mode

cwebp cannot pad. Some game screens run edge to edge, so trimming them to 16:10 would cut content. Those are padded out to 16:10 instead, and padding is sips -p height width --padColor.

But sips ignores --cropOffset. You can ask it to crop to a size and it will do so from the centre; ask it to crop at an offset and the offset is silently dropped. So the full path is three steps:

  1. cwebp -lossless -crop ... cuts the area between the header and the bottom bar.
  2. dwebp turns that back into a PNG, because sips does not read WebP.
  3. sips -p ... --padColor 171717 pads it to 16:10, which it centres, which is exactly what is wanted here.

171717 is the dark theme --background, hsl(0 0% 9%), written as hex because sips wants hex. It is the one value in the pipeline that is a copy of something in the CSS, and it is a copy because sips has no way to be told "the colour that variable has today".

The output is 960 pixels wide at quality 80, which lands between 2 KB and 72 KB per card, with the middle of the set around 12 KB. The script also writes a PNG copy of every result into a temp directory, for the mundane reason that not every image viewer opens WebP and you want to look at what you just made.

The manifest is generated from the directory

A card renders a screenshot only when one exists. The list of which games have one is not maintained by hand:

const ids = readdirSync(target.outDir)
  .filter((file) => file.endsWith('.webp'))
  .map((file) => file.replace(/\.webp$/, ''))
  .sort();
Enter fullscreen mode Exit fullscreen mode

and written out as a typed set:

/**
 * Games with a card screenshot at public/games/screenshots/<id>.webp.
 * Generated by `pnpm tsx scripts/game-screenshots.ts manifest`; do not edit by hand.
 */
export const GAME_SCREENSHOTS: ReadonlySet<string> = new Set([
  'arrows',
  'balance',
  // ...370 more
]);
Enter fullscreen mode Exit fullscreen mode

The point of generating it is not saving typing. It is that the file on disk and the file the card asks for cannot disagree. A screenshot deleted from the directory disappears from the set on the next run, and the card falls back to its no image layout instead of rendering a broken image. A screenshot added and never listed is simply not shown, which is a missing feature rather than a 404 in somebody's card.

The same script serves assessment centre exercises with a different viewport height, a different header height and a 60 pixel bottom bar to exclude, which is the entire difference between the two targets:

exercise: {
  viewportHeight: 916,
  headerHeight: 56,
  footerHeight: 60,
}
Enter fullscreen mode Exit fullscreen mode

916 is 800 plus 56 plus 60, chosen so that the area between the header and the bar is exactly 1280 by 800. The numbers are not arbitrary, they are the layout arithmetic written down once in the place that crops.

If you want to see what comes out of it, the cards are on every provider page: SHL, Aon, or the full provider list.

Top comments (0)