storebound/media/teaser/desktop.ts
CRT f07f3acbd8 teaser v4: calm 60 s 16:9 cut, from an empty folder to ready on both stores
Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top
(on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt
and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and
`catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning
green, the desktop release timeline and Store readiness "No blockers", Submit and the end card.

Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from
tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths,
caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB.
Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
2026-10-12 01:48:03 +08:00

135 lines
7.3 KiB
TypeScript

#!/usr/bin/env bun
// Real desktop-app frames for the teaser's desktop scene: 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
// 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 };
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 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');
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();
writeFileSync(join(OUT, 'boxes.json'), `${JSON.stringify(boxes, null, 1)}\n`);
await browser.close();
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
log(`6 frames + boxes.json -> ${OUT}`);
} finally {
vite.kill();
}
}
if (import.meta.main) await captureDesktop();