feat(screenshots): automate flathub output

This commit is contained in:
Johannes Millan 2026-05-11 13:35:17 +02:00
parent bca1dba174
commit 4534789571
4 changed files with 212 additions and 34 deletions

View file

@ -5,7 +5,7 @@ Reproducible app-store screenshots driven by Playwright + a single seed dataset.
## Quick start
```bash
# Capture everything (all viewports × all scenarios that apply to that platform)
# Capture the web-store matrix (all web viewports × matching scenarios)
npm run screenshots
# Or split:
@ -14,6 +14,8 @@ npm run screenshots:capture:desktop # desktopMaster only
npm run screenshots:capture:mobile # iPhone/iPad/Android viewports only
npm run screenshots:capture:electron # Electron build → dist/screenshots/_master_electron/
npm run screenshots:electron # capture:electron + build (lands in dist/)
npm run screenshots:build:flathub # rebuild only dist/screenshots/flathub/
npm run screenshots:flathub # Linux Electron capture + Flathub-ready build
npm run screenshots:build # rebuild dist/ layout from existing masters
# One group while iterating
@ -28,8 +30,9 @@ npx playwright test --config e2e/playwright.store-screenshots.config.ts \
| `SCREENSHOT_MODE=electron` | Switches the fixture to the Electron pipeline (set by `screenshots:capture:electron`). |
| `SCREENSHOT_BASE_DATE=2026-05-06T09:30:00` | Pin the "today" anchor used by the seed builder. Default is a Wednesday well clear of midnight in CI timezones. |
| `SP_SCREENSHOT_BG_DARK_URL` / `SP_SCREENSHOT_BG_LIGHT_URL` | Override the default Unsplash backgrounds (e.g. point to a vendored asset for offline / privacy-sensitive runs). |
| `SP_SCREENSHOT_BG_DISABLE=1` | Drop background images entirely. |
| `SP_SCREENSHOT_BG_DISABLE=1` | Drop background images entirely (set by `screenshots:flathub`). |
| `SP_SCREENSHOT_BG_OVERLAY_OPACITY=80` | Drives the per-context "Darken/lighten background image for better contrast" slider (099). Default 80 for screenshots vs. 20 in the app. |
| `SP_SCREENSHOTS_STORE=flathub` | Restrict post-processing to one store rule. Used by `screenshots:build:flathub` so Linux captures do not regenerate Mac App Store output. |
Master captures land in `dist/screenshots/_master/<viewport>/<locale>/<theme>/<scenario>/<name>.png`.
Per-store assets land in `dist/screenshots/<store>/<locale>/NN-name.png` (and the F-Droid `fastlane/...` layout).
@ -74,7 +77,7 @@ Specs are platform-grouped: `scenarios/desktop/all.spec.ts` and `scenarios/mobil
| `scenarios/desktop/all.spec.ts` | 12 desktop captures: hero + 11 scenes / light variants |
| `scenarios/mobile/all.spec.ts` | 7 mobile slots: hero + 6 scenes |
| `scenarios/tablet/all.spec.ts` | 6 tablet slots: hero + 5 scenes |
| `build-store-assets.ts` | Renames + copies masters into shared/per-store layouts; JPEG re-encode for `maxBytes`-capped stores (Snap); emits `_preview.html` contact sheet |
| `build-store-assets.ts` | Renames + copies masters into shared/per-store layouts; filters/frames Flathub; JPEG re-encode for `maxBytes`-capped stores (Snap); emits `_preview.html` contact sheet |
| `../playwright.store-screenshots.config.ts` | Separate Playwright config; one project per viewport |
## How it works
@ -113,7 +116,7 @@ for (const locale of LOCALES) {
- **Snap** uses the same desktop master content but stays separate because it caps at 5 items, ≤2 MB each, single global gallery (no per-locale). Pipeline emits all desktops re-encoded as JPEG (mozjpeg, q90→q60 step-down) so each fits the cap; **trim manually to 5 before Snap upload**.
- **Play / Apple** explicitly forbid / discourage device frames.
- **Apple** requires only iPhone 6.9" (1290×2796) and iPad 13" (2064×2752); smaller sizes auto-derive.
- **Flathub** requires native window chrome and forbids overlays — sourced from the Electron capture pipeline (single global gallery; run on a Linux X11/Wayland host via `npm run screenshots:capture:electron`).
- **Flathub** requires native window chrome and forbids overlays — sourced from the Electron capture pipeline (single global gallery). Run on a Linux X11/Wayland host via `npm run screenshots:flathub`; it disables decorative backgrounds, drops the marketing hero/duplicate variants, and frames the final PNGs with transparent rounded corners + shadow.
## Electron pipeline (Mac App Store, Flathub)
@ -126,6 +129,10 @@ npm run screenshots:capture:electron
# Capture + build — masters and deliverables under dist/screenshots/
# (macappstore/, flathub/). Mirrors `npm run screenshots` for the web pipeline.
npm run screenshots:electron
# Flathub-ready Linux capture + targeted build. Disables decorative backgrounds
# and emits the filtered/framed dist/screenshots/flathub/ gallery.
npm run screenshots:flathub
```
Same scenarios, same fixture file — `store-screenshots/fixture.ts` branches on the `SCREENSHOT_MODE` env var (the npm script sets it). Each desktop spec runs unchanged in either mode.
@ -140,7 +147,7 @@ Per-OS tooling (must be on PATH):
- **Linux X11** — ImageMagick (`apt install imagemagick`, ships `import`)
- **Linux Wayland**`grim` (`apt install grim`, wlroots-based compositors only)
The Mac App Store store rule has `masterDir: 'electron'` in `STORE_RULES`, so the post-processor pulls those captures into `dist/screenshots/macappstore/`. All other stores still come from the web pipeline.
The Mac App Store and Flathub store rules have `masterDir: 'electron'` in `STORE_RULES`, so the post-processor pulls those captures into `dist/screenshots/macappstore/` and `dist/screenshots/flathub/`. Flathub additionally pins its gallery order and applies rounded transparent window framing in `build-store-assets.ts`. All other stores still come from the web pipeline.
## Status
@ -149,7 +156,7 @@ The Mac App Store store rule has `masterDir: 'electron'` in `STORE_RULES`, so th
- ✅ Per-build `_preview.html` contact sheet under `dist/screenshots/` for one-click QA
- ✅ Electron-mode pipeline with macOS traffic-light compositing and Linux OS chrome capture (`grim` / `import`)
- ✅ Mac App Store wired to source from `_master_electron/`
- ✅ Flathub STORE_RULE (single-gallery, sourced from `_master_electron/`)
- ✅ Flathub STORE_RULE (single-gallery, sourced from `_master_electron/`, filtered/framed for Flathub)
- ✅ Snap JPEG re-encode under 2 MB cap (mozjpeg, automatic per-file)
- ✅ Tooltip suppression + cursor parking so leftover Material tooltips don't bleed into captures
- ⏳ Smoke-test the Electron pipeline on a real Mac (or Linux X11 for plumbing)

View file

@ -7,7 +7,8 @@
* Captures are made at each store's native pixel size (see playwright.store-screenshots.config.ts),
* so this is mostly rename + copy no resizing. Stores with a per-file byte
* cap (`maxBytes` in STORE_RULES; currently Snap @ 2 MB) get re-encoded as
* JPEG until they fit; everything else stays lossless PNG.
* JPEG until they fit; everything else stays lossless PNG. Store rules can also
* filter/reorder scenarios and apply store-specific framing (Flathub).
*
* Run: npm run screenshots:build
*/
@ -31,11 +32,70 @@ const masterDirFor = (which: 'web' | 'electron'): string =>
which === 'electron' ? MASTER_DIR_ELECTRON : MASTER_DIR_WEB;
type ScenarioFile = { scenario: string; file: string; theme: string; absPath: string };
type WriteOptions = {
maxBytes?: number;
frame?: StoreRule['frame'];
};
const FLATHUB_WINDOW_FRAME = {
padding: 48,
radius: 16,
shadowBlur: 24,
shadowOffsetY: 12,
shadowOpacity: 0.35,
} as const;
/** Strip leading `(mobile|desktop)-NN-` so output names don't double up on the index. */
const cleanScenarioLabel = (scenario: string): string =>
scenario.replace(/^(?:(?:mobile|desktop)-)?\d+-/, '');
const labelForScenario = (rule: StoreRule, scenario: string): string =>
rule.scenarioLabels?.find((entry) => entry.scenario === scenario)?.label ??
cleanScenarioLabel(scenario);
const applyScenarioOrder = (
scenarios: ScenarioFile[],
order?: readonly string[],
store?: string,
): ScenarioFile[] => {
if (!order) return scenarios;
const byScenario = new Map<string, ScenarioFile>();
for (const scenario of scenarios) byScenario.set(scenario.scenario, scenario);
return order.map((scenario) => {
const match = byScenario.get(scenario);
if (!match) {
const tag = store ? `${store}: ` : '';
throw new Error(`${tag}missing ordered screenshot scenario "${scenario}"`);
}
return match;
});
};
const storeFilter = (): Set<string> | null => {
const raw = process.env.SP_SCREENSHOTS_STORE;
if (!raw) return null;
const stores = raw
.split(',')
.map((store) => store.trim())
.filter(Boolean);
return stores.length ? new Set(stores) : null;
};
const filterStoreRules = (rules: readonly StoreRule[]): StoreRule[] => {
const filter = storeFilter();
if (!filter) return [...rules];
const out = rules.filter((rule) => filter.has(rule.store));
if (out.length === 0) {
throw new Error(
`SP_SCREENSHOTS_STORE matched no store rules: ${[...filter].join(', ')}`,
);
}
return out;
};
/**
* Each scenario locks one theme. List all (theme × scenario) captures present
* for a given viewport+locale, sorted by scenario name (which encodes slot order
@ -74,8 +134,73 @@ const listScenariosForVariant = (
return out;
};
const transparentBg = { r: 0, g: 0, b: 0, alpha: 0 } as const;
const roundedRectSvg = (
width: number,
height: number,
radius: number,
fill: string,
extraAttrs = '',
): Buffer =>
Buffer.from(
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg"><rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="${fill}" ${extraAttrs}/></svg>`,
);
const frameFlathubWindow = async (src: string): Promise<Buffer> => {
const metadata = await sharp(src).metadata();
const width = metadata.width;
const height = metadata.height;
if (!width || !height) {
throw new Error(`Could not read screenshot dimensions for ${src}`);
}
const { padding, radius, shadowBlur, shadowOffsetY, shadowOpacity } =
FLATHUB_WINDOW_FRAME;
const framePadding = padding * 2;
const frameWidth = width + framePadding;
const frameHeight = height + framePadding;
const windowMask = roundedRectSvg(width, height, radius, '#fff');
const shadowShape = roundedRectSvg(
width,
height,
radius,
'#000',
`fill-opacity="${shadowOpacity}"`,
);
const roundedWindow = await sharp(src)
.ensureAlpha()
.composite([{ input: windowMask, blend: 'dest-in' }])
.png()
.toBuffer();
const shadow = await sharp({
create: {
width: frameWidth,
height: frameHeight,
channels: 4,
background: transparentBg,
},
})
.composite([{ input: shadowShape, left: padding, top: padding + shadowOffsetY }])
.blur(shadowBlur)
.png()
.toBuffer();
return sharp(shadow)
.composite([{ input: roundedWindow, left: padding, top: padding }])
.png()
.toBuffer();
};
const renderSource = async (src: string, frame?: StoreRule['frame']): Promise<Buffer> => {
if (frame === 'flathub-window') return frameFlathubWindow(src);
return fs.readFileSync(src);
};
/**
* Copy `src` to `target`, then enforce `maxBytes` if set. PNGs that already
* Render `src` to `target`, then enforce `maxBytes` if set. PNGs that already
* fit go through unchanged. PNGs that exceed the cap are re-encoded as JPEG
* at stepped-down quality until they fit; the .png target is replaced with a
* .jpg sibling. Returns the actual path written.
@ -83,29 +208,30 @@ const listScenariosForVariant = (
const writeWithCap = async (
src: string,
target: string,
maxBytes?: number,
opts: WriteOptions = {},
): Promise<string> => {
fs.copyFileSync(src, target);
if (!maxBytes) return target;
const initialSize = fs.statSync(target).size;
if (initialSize <= maxBytes) return target;
const buf = await renderSource(src, opts.frame);
fs.writeFileSync(target, buf);
if (!opts.maxBytes) return target;
if (buf.byteLength <= opts.maxBytes) return target;
const jpgTarget = target.replace(/\.png$/i, '.jpg');
const buf = fs.readFileSync(src);
// Step down quality 90 → 60. If even q60 exceeds the cap (vanishingly rare
// on screenshots that are mostly UI), bail loudly so the operator notices.
for (const quality of [90, 85, 80, 75, 70, 65, 60]) {
const out = await sharp(buf)
.jpeg({ quality, mozjpeg: true, progressive: true, chromaSubsampling: '4:2:0' })
.toBuffer();
if (out.byteLength <= maxBytes) {
if (out.byteLength <= opts.maxBytes) {
fs.writeFileSync(jpgTarget, out);
if (jpgTarget !== target) fs.rmSync(target);
return jpgTarget;
}
}
throw new Error(
`${path.basename(target)} still > ${maxBytes} bytes at JPEG quality 60 — capture too large`,
`${path.basename(target)} still > ${
opts.maxBytes
} bytes at JPEG quality 60 capture too large`,
);
};
@ -114,7 +240,7 @@ const writeFastlanePath = async (
bucket: NonNullable<StoreRule['fastlaneBucket']>,
index: number,
src: string,
maxBytes?: number,
opts: WriteOptions = {},
): Promise<string> => {
const bucketDir = `${bucket}Screenshots`;
const dir = path.join(
@ -129,36 +255,36 @@ const writeFastlanePath = async (
);
fs.mkdirSync(dir, { recursive: true });
const target = path.join(dir, `${index + 1}.png`);
return writeWithCap(src, target, maxBytes);
return writeWithCap(src, target, opts);
};
const writePerLocale = async (
store: string,
locale: string,
index: number,
scenario: string,
label: string,
src: string,
maxBytes?: number,
opts: WriteOptions = {},
): Promise<string> => {
const dir = path.join(OUT_DIR, store, locale);
fs.mkdirSync(dir, { recursive: true });
const num = `${index + 1}`.padStart(2, '0');
const target = path.join(dir, `${num}-${cleanScenarioLabel(scenario)}.png`);
return writeWithCap(src, target, maxBytes);
const target = path.join(dir, `${num}-${label}.png`);
return writeWithCap(src, target, opts);
};
const writeGlobal = async (
store: string,
index: number,
scenario: string,
label: string,
src: string,
maxBytes?: number,
opts: WriteOptions = {},
): Promise<string> => {
const dir = path.join(OUT_DIR, store);
fs.mkdirSync(dir, { recursive: true });
const num = `${index + 1}`.padStart(2, '0');
const target = path.join(dir, `${num}-${cleanScenarioLabel(scenario)}.png`);
return writeWithCap(src, target, maxBytes);
const target = path.join(dir, `${num}-${label}.png`);
return writeWithCap(src, target, opts);
};
const buildOneRule = async (
@ -169,21 +295,29 @@ const buildOneRule = async (
const masterRoot = masterDirFor(rule.masterDir ?? 'web');
for (const locale of LOCALES) {
const scenarios = listScenariosForVariant(rule.source, locale, masterRoot);
const scenarios = applyScenarioOrder(
listScenariosForVariant(rule.source, locale, masterRoot),
rule.scenarioOrder,
rule.store,
);
if (scenarios.length === 0) {
skipped += 1;
continue;
}
const limited = rule.maxCount ? scenarios.slice(0, rule.maxCount) : scenarios;
const writeOptions: WriteOptions = {};
if (rule.maxBytes) writeOptions.maxBytes = rule.maxBytes;
if (rule.frame) writeOptions.frame = rule.frame;
for (let i = 0; i < limited.length; i += 1) {
const s = limited[i];
const label = labelForScenario(rule, s.scenario);
if (rule.localeLayout === 'fastlane' && rule.fastlaneBucket) {
await writeFastlanePath(locale, rule.fastlaneBucket, i, s.absPath, rule.maxBytes);
await writeFastlanePath(locale, rule.fastlaneBucket, i, s.absPath, writeOptions);
} else if (rule.localeLayout === 'global') {
if (locale !== LOCALES[0]) continue; // Snap / Flathub are single-gallery
await writeGlobal(rule.store, i, s.scenario, s.absPath, rule.maxBytes);
await writeGlobal(rule.store, i, label, s.absPath, writeOptions);
} else {
await writePerLocale(rule.store, locale, i, s.scenario, s.absPath, rule.maxBytes);
await writePerLocale(rule.store, locale, i, label, s.absPath, writeOptions);
}
written += 1;
}
@ -203,6 +337,17 @@ const cleanDerivedOutput = (): void => {
}
};
const cleanSelectedOutput = (rules: readonly StoreRule[]): void => {
fs.mkdirSync(OUT_DIR, { recursive: true });
const targets = new Set<string>();
for (const rule of rules) {
targets.add(rule.localeLayout === 'fastlane' ? 'fdroid' : rule.store);
}
for (const target of targets) {
fs.rmSync(path.join(OUT_DIR, target), { recursive: true, force: true });
}
};
const main = async (): Promise<void> => {
if (!fs.existsSync(MASTER_DIR_WEB) && !fs.existsSync(MASTER_DIR_ELECTRON)) {
console.error('No master captures found.');
@ -212,10 +357,12 @@ const main = async (): Promise<void> => {
process.exit(1);
}
cleanDerivedOutput();
const rules = filterStoreRules(STORE_RULES);
if (storeFilter()) cleanSelectedOutput(rules);
else cleanDerivedOutput();
let total = 0;
for (const rule of STORE_RULES) {
for (const rule of rules) {
const res = await buildOneRule(rule);
total += res.written;
const tag = rule.fastlaneBucket ? `${rule.store}/${rule.fastlaneBucket}` : rule.store;

View file

@ -133,6 +133,12 @@ export type StoreRule = {
* each entry at 2 MB; everything else keeps the lossless PNG.
*/
maxBytes?: number;
/** Optional explicit gallery order/filter for stores with tighter curation. */
scenarioOrder?: readonly string[];
/** Optional output filename labels for raw scenario ids. */
scenarioLabels?: readonly { scenario: string; label: string }[];
/** Optional post-processing frame applied before writing the store output. */
frame?: 'flathub-window';
};
export const STORE_RULES: readonly StoreRule[] = [
@ -158,13 +164,28 @@ export const STORE_RULES: readonly StoreRule[] = [
maxBytes: 2 * 1024 * 1024,
},
// Flathub: metainfo.xml `<screenshot>` is single-gallery and sourced from the
// Electron pipeline so the captures include native GTK chrome (Linux X11
// host required — see README "Electron pipeline" section).
// Electron pipeline so the captures include native GTK chrome. Keep the
// gallery concise: no marketing hero, no duplicate light/dark variants.
{
store: 'flathub',
source: 'desktopMaster',
localeLayout: 'global',
masterDir: 'electron',
scenarioOrder: [
'desktop-01-list-with-schedule',
'desktop-08-planner',
'desktop-02-eisenhower',
'desktop-03-schedule-dark',
'desktop-04-list-with-notes',
'desktop-05-focus-mode',
'desktop-07-project-dark',
'desktop-10-task-detail-panel',
],
scenarioLabels: [
{ scenario: 'desktop-03-schedule-dark', label: 'schedule' },
{ scenario: 'desktop-07-project-dark', label: 'project' },
],
frame: 'flathub-window',
},
{
store: 'ios/iphone-69',

View file

@ -75,8 +75,11 @@
"screenshots:capture:desktop": "npx playwright test --config e2e/playwright.store-screenshots.config.ts --project=desktopMaster",
"screenshots:capture:mobile": "npx playwright test --config e2e/playwright.store-screenshots.config.ts --project=iphone69 --project=ipad13 --project=androidPhone --project=android7Tablet --project=android10Tablet",
"screenshots:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-screenshots/build-store-assets.ts",
"screenshots:build:flathub": "SP_SCREENSHOTS_STORE=flathub npm run screenshots:build",
"screenshots:capture:electron": "npm run electron:build && SCREENSHOT_MODE=electron npx playwright test --config e2e/playwright.store-screenshots.electron.config.ts",
"screenshots:capture:flathub": "SP_SCREENSHOT_BG_DISABLE=1 npm run screenshots:capture:electron",
"screenshots:electron": "npm run screenshots:capture:electron && npm run screenshots:build",
"screenshots:flathub": "npm run screenshots:capture:flathub && npm run screenshots:build:flathub",
"video": "npm run video:capture && npm run video:build && npm run video:open",
"video:capture": "npx playwright test --config e2e/playwright.store-video.config.ts",
"video:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/build-video.ts",