Portfolio (every app, both stores, rollout %, crash sparklines), the Growth and Health charts with a real hover sweep (one captured frame per hovered day, the pointer follows the recorded positions), the Prices matrix (both stores per country, gaps marked), then v5's release timeline and 0 blockers. All frames are real fixture-mode captures (desktop.ts). Tests: scenes 4-5 s, 50-60 s total, captions >= 3 s, every desktop frame captured by desktop.ts and present.
203 lines
12 KiB
TypeScript
203 lines
12 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Real desktop-app frames for the teaser's desktop scenes: the Vite dev server of apps/desktop in fixture mode
|
|
// (VITE_FIXTURES=1: example.com-style fixture data, no Tauri, no store accounts), captured headlessly, dark theme,
|
|
// with the two dev-build markers hidden (they are not in a release build).
|
|
// Writes media/teaser/out/desktop/*.jpg plus boxes.json (callout targets, as fractions of the frame).
|
|
// portfolio.jpg Portfolio: every app of every studio, grouped by studio
|
|
// portfolio-apps.jpg the same, scrolled to the Apps table: both stores per app, rollout %, crash-rate sparklines
|
|
// growth.jpg Prism Puddle's Growth tab: the store analytics chart (visx), pointer outside
|
|
// growth-NN.jpg the same while the pointer (real mouse moves) sweeps across the chart: one frame per hovered
|
|
// day, the crosshair and tooltip showing that day's impressions, downloads and conversion
|
|
// health.jpg the Health tab: the crash and ANR rate charts, pointer outside
|
|
// health-NN.jpg the pointer sweeping across the crash-rate chart, one frame per hovered day
|
|
// prices.jpg the Prices tab: products x key territories, both stores' prices per country, gaps marked
|
|
// releases.jpg Prism Puddle's release timeline, both stores rolling out at 20%
|
|
// releases-empty.jpg the same frame with the rollout bars' fill and "20%" hidden (the video fills the bar)
|
|
// checks.jpg the Checks tab: the "Rejection risks" card with its findings
|
|
// checks-clean.jpg the same tab after the fixes: the fixture's scan answer with no findings (the app's own
|
|
// "No rejection risks found" state; the fixture module is served with an empty findings list)
|
|
// ready.jpg the same tab scrolled to "Store readiness" (validate, both stores) after the fixes: the
|
|
// fixture's validate answer with no findings (the app's own "No blockers" state)
|
|
// render.ts runs it when out/desktop/ is missing; run it by hand after a UI change: bun media/teaser/desktop.ts
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { launch } from '../../cli/shots/browser';
|
|
|
|
const DIR = import.meta.dir;
|
|
const ROOT = join(DIR, '../..');
|
|
const OUT = join(DIR, 'out/desktop');
|
|
export const VIEW = { width: 1280, height: 800 };
|
|
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [desktop] ${m}`);
|
|
|
|
type Box = { x: number; y: number; w: number; h: number };
|
|
/** One frame of a hover sweep: the pointer position (fraction of the frame) at which this day starts to show. */
|
|
type SweepFrame = { file: string; px: number; py: number; day: string };
|
|
/** Hover sweeps: the chart, the part of its width the pointer crosses, the height it moves at. */
|
|
const SWEEPS = {
|
|
growth: { start: 'app:growth', svg: '.lchart svg', x: [0.2, 0.86], y: 0.42 },
|
|
health: { start: 'app:health', svg: '.card:has-text("User-perceived crash rate") .spark svg', x: [0.12, 0.9], y: 0.45 },
|
|
} as const;
|
|
|
|
export async function captureDesktop(): Promise<void> {
|
|
mkdirSync(OUT, { recursive: true });
|
|
const port = 14_300 + Math.floor(Math.random() * 500);
|
|
const vite = Bun.spawn(['bunx', 'vite', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
|
|
cwd: join(ROOT, 'apps/desktop'), env: { ...process.env, VITE_FIXTURES: '1' }, stdout: 'ignore', stderr: 'pipe',
|
|
});
|
|
const base = `http://127.0.0.1:${port}`;
|
|
try {
|
|
for (let i = 0; ; i++) {
|
|
try { if ((await fetch(base)).ok) break; } catch { /* not up yet */ }
|
|
if (i > 120) throw new Error('vite dev server did not start');
|
|
await Bun.sleep(250);
|
|
}
|
|
const browser = await launch(ROOT, { headless: true });
|
|
const boxes: Record<string, Box> = {};
|
|
const sweeps: Record<string, SweepFrame[]> = {};
|
|
const errors: string[] = [];
|
|
|
|
const session = async (clean: boolean) => {
|
|
const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, colorScheme: 'dark', reducedMotion: 'reduce' });
|
|
if (clean) {
|
|
// after the fixes: the fixture's scan of Prism Puddle answers with no findings (the UI is unchanged)
|
|
await ctx.route(/\/src\/dev\/fixtures\.ts/, async (route: any) => {
|
|
const res = await route.fetch();
|
|
const src = await res.text();
|
|
const scan = src.replace(/findings:\s*SCAN_PRISM,\s*counts:\s*scanCounts\(SCAN_PRISM\)/, 'findings: [], counts: scanCounts([])');
|
|
if (scan === src) errors.push('fixtures.ts: the Prism Puddle scan entry moved (desktop.ts clean capture)');
|
|
// ... and its store readiness report (validate) answers with no blockers and no findings
|
|
const body = scan.replace(/(["']p-prism["']:\s*\{\s*blockers:\s*)1(,\s*findings:\s*)\[[^\]]*\]/, '$10$2[]');
|
|
if (body === scan) errors.push('fixtures.ts: the Prism Puddle validate entry moved (desktop.ts clean capture)');
|
|
await route.fulfill({ response: res, body });
|
|
});
|
|
}
|
|
const page = await ctx.newPage();
|
|
page.on('pageerror', (e: unknown) => errors.push(String(e)));
|
|
return { ctx, page };
|
|
};
|
|
const open = async (page: any, start: string): Promise<void> => {
|
|
await page.goto(`${base}/?start=${encodeURIComponent(start)}&theme=dark&lang=en`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.addStyleTag({ content: '.brand small, .dev-pill { display: none !important; }' });
|
|
await page.waitForTimeout(1200);
|
|
};
|
|
const box = async (page: any, name: string, loc: any): Promise<void> => {
|
|
const b = await loc.first().boundingBox();
|
|
if (!b) throw new Error(`no box for ${name}`);
|
|
boxes[name] = { x: b.x / VIEW.width, y: b.y / VIEW.height, w: b.width / VIEW.width, h: b.height / VIEW.height };
|
|
};
|
|
const shot = async (page: any, name: string): Promise<void> => {
|
|
await page.mouse.move(VIEW.width - 2, VIEW.height - 2);
|
|
await page.waitForTimeout(200);
|
|
await page.screenshot({ path: join(OUT, `${name}.jpg`), type: 'jpeg', quality: 93 });
|
|
};
|
|
|
|
const a = await session(false);
|
|
await open(a.page, 'portfolio');
|
|
await box(a.page, 'studios', a.page.locator('.fchips'));
|
|
await box(a.page, 'table', a.page.locator('table').first());
|
|
await shot(a.page, 'portfolio');
|
|
|
|
// the Apps table: both stores per app, rollouts, crash-rate sparklines
|
|
await a.page.evaluate(() => document.getElementById('content')?.scrollBy({ top: 140 }));
|
|
await a.page.waitForTimeout(300);
|
|
await box(a.page, 'appsTable', a.page.locator('table').first());
|
|
await box(a.page, 'prismRow', a.page.locator('tr').filter({ hasText: 'Prism Puddle' }).first());
|
|
await shot(a.page, 'portfolio-apps');
|
|
|
|
// charts: the Growth tab's store analytics and the Health tab's crash rate, then a pointer sweep across each
|
|
const sweep = async (name: keyof typeof SWEEPS, scroll: number): Promise<void> => {
|
|
const sw = SWEEPS[name];
|
|
await open(a.page, sw.start);
|
|
if (scroll) await a.page.evaluate((y: number) => document.getElementById('content')?.scrollBy({ top: y }), scroll);
|
|
await a.page.waitForTimeout(300);
|
|
await shot(a.page, name);
|
|
const svg = a.page.locator(sw.svg).first();
|
|
const b = await svg.boundingBox();
|
|
if (!b) throw new Error(`no chart for ${name}`);
|
|
const y = b.y + b.height * sw.y;
|
|
const frames: SweepFrame[] = [];
|
|
let last = '';
|
|
for (let x = b.x + b.width * sw.x[0]; x <= b.x + b.width * sw.x[1]; x += 2) {
|
|
await a.page.mouse.move(x, y);
|
|
// the tooltip's text names the hovered day (and its values): a new text = a new day
|
|
const day = ((await a.page.locator('.vx-tip').first().textContent({ timeout: 2000 }).catch(() => '')) ?? '').trim();
|
|
if (!day || day === last) continue;
|
|
last = day;
|
|
const file = `${name}-${String(frames.length).padStart(2, '0')}`;
|
|
await a.page.waitForTimeout(60);
|
|
await a.page.screenshot({ path: join(OUT, `${file}.jpg`), type: 'jpeg', quality: 93 });
|
|
frames.push({ file, px: x / VIEW.width, py: y / VIEW.height, day });
|
|
}
|
|
if (frames.length < 8) throw new Error(`${name}: the sweep hovered only ${frames.length} days`);
|
|
sweeps[name] = frames;
|
|
};
|
|
await sweep('growth', 150);
|
|
await box(a.page, 'growthPlot', a.page.locator('.lchart-plot').first());
|
|
await sweep('health', 0);
|
|
await box(a.page, 'crashCard', a.page.locator('.card').filter({ hasText: 'User-perceived crash rate' }).first());
|
|
|
|
// prices: products x key territories, both stores' prices per country, the gaps marked
|
|
await open(a.page, 'app:prices');
|
|
const matrix = a.page.locator('.card').filter({ hasText: 'Products x key territories' }).first();
|
|
await matrix.evaluate((el: HTMLElement) => {
|
|
el.scrollIntoView({ block: 'start' });
|
|
document.getElementById('content')?.scrollBy({ top: -125 });
|
|
});
|
|
await a.page.waitForTimeout(300);
|
|
await box(a.page, 'priceMatrix', matrix);
|
|
await box(a.page, 'priceGap', matrix.locator('.chip').filter({ hasText: /Δ 2\d/ }).first());
|
|
await shot(a.page, 'prices');
|
|
|
|
await open(a.page, 'app:releases');
|
|
// the release timeline (both stores, rollout bars) at the top of the frame
|
|
await a.page.locator('.tl-lane').first().evaluate((el: HTMLElement) => {
|
|
el.scrollIntoView({ block: 'start' });
|
|
document.getElementById('content')?.scrollBy({ top: -150 });
|
|
});
|
|
await a.page.waitForTimeout(300);
|
|
await box(a.page, 'roll', a.page.locator('.tl-roll').first());
|
|
await box(a.page, 'roll2', a.page.locator('.tl-roll').nth(1));
|
|
await box(a.page, 'timeline', a.page.locator('.tl-lane').first());
|
|
await shot(a.page, 'releases');
|
|
await a.page.addStyleTag({ content: '.tl-roll .rbar rect:not(.track) { display: none !important; } .tl-roll .pct { visibility: hidden !important; }' });
|
|
await a.page.waitForTimeout(150);
|
|
await shot(a.page, 'releases-empty');
|
|
|
|
await open(a.page, 'app:checks');
|
|
await box(a.page, 'scan', a.page.locator('.card').first());
|
|
await box(a.page, 'finding', a.page.locator('.card').first().locator('.li').first());
|
|
await box(a.page, 'scanAgain', a.page.locator('.card').first().locator('.btn').first());
|
|
await shot(a.page, 'checks');
|
|
await a.ctx.close();
|
|
|
|
const b = await session(true);
|
|
await open(b.page, 'app:checks');
|
|
await box(b.page, 'scanClean', b.page.locator('.card').first());
|
|
await shot(b.page, 'checks-clean');
|
|
// the store readiness card (validate) at the top of the view: "No blockers" for both stores
|
|
// (room below the last row, so the card can scroll up to the top of the view)
|
|
await b.page.addStyleTag({ content: '#content { padding-bottom: 640px !important; }' });
|
|
const ready = b.page.locator('.card').filter({ hasText: 'Store readiness' }).first();
|
|
await ready.evaluate((el: HTMLElement) => {
|
|
el.scrollIntoView({ block: 'start' });
|
|
document.getElementById('content')?.scrollBy({ top: -120 });
|
|
});
|
|
await b.page.waitForTimeout(300);
|
|
await box(b.page, 'ready', ready);
|
|
await box(b.page, 'readyEmpty', ready.locator('.empty, [class*=empty]').first());
|
|
await shot(b.page, 'ready');
|
|
await b.ctx.close();
|
|
|
|
const source = 'apps/desktop, Vite dev server, VITE_FIXTURES=1, headless Chromium 1280x800 @2x, dark';
|
|
writeFileSync(join(OUT, 'boxes.json'), `${JSON.stringify({ ...boxes, source, sweeps }, null, 1)}\n`);
|
|
await browser.close();
|
|
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
|
const n = 9 + Object.values(sweeps).reduce((k, f) => k + f.length, 0);
|
|
log(`${n} frames (${Object.entries(sweeps).map(([k, f]) => `${k} sweep: ${f.length} days`).join(', ')}) + boxes.json -> ${OUT}`);
|
|
} finally {
|
|
vite.kill();
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) await captureDesktop();
|