diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index 3fd04d688..0ff05e5e4 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -57,6 +57,20 @@ consumers currently use relative `@use` paths to the needed partial. cap in rules that set that family. Read the guidelines' Typography section before changing bundled fonts. +## Rendering Cost + +- A composited layer the size of the content area costs about 24 MB of + tile memory at 2x against Chromium's 512 MB budget; contract in + `docs/architecture/performance-journeys.md` (Compositing budget). +- No `border-radius`, `mask` or `clip-path` on a scroller or another + ancestor of `backdrop-filter`, render-surface `filter`, running + animations, `isolation: isolate` or `will-change`: Blink then clips each + through a mask layer the size of the clipped area. Paint the corner, as + `.workspace-content-frame` does. +- Adding or changing those properties: run `pnpm run perf:compositing` + before and after, quote the numbers in the PR, and extend + `compositing.e2e.ts` in `apps/electron-backend-e2e` for a new route. + ## Validation Run the affected consumer's Nx lint/test/build target. Inspect light and dark diff --git a/.codex/skills/iptvnator-ui-design/SKILL.md b/.codex/skills/iptvnator-ui-design/SKILL.md index 83eecc133..3554b796d 100644 --- a/.codex/skills/iptvnator-ui-design/SKILL.md +++ b/.codex/skills/iptvnator-ui-design/SKILL.md @@ -42,3 +42,6 @@ not UI or util. Run the focused component/unit target and the closest Playwright workflow for a visible change. Electron CDP is a fallback for Electron-only gaps, not a replacement for an available E2E flow. Record any uncovered visual state. +A change that adds `backdrop-filter`, `filter`, animations or a rounded +scroller also runs `pnpm run perf:compositing` (see the theme/style skill's +Rendering Cost section) and quotes the numbers. diff --git a/README.md b/README.md index ae7f09d20..95f767353 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,9 @@ $ pnpm run perf:journeys The journeys, their counters and the summary written under `dist/performance/journeys/` are described in [docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). +`pnpm run perf:compositing` measures the renderer's tile memory and composited +layers on the dashboard, the detail pages, Live TV and settings (the +"compositing budget" section of the same document). ## Disclaimer diff --git a/apps/electron-backend-e2e/playwright.compositing.config.ts b/apps/electron-backend-e2e/playwright.compositing.config.ts new file mode 100644 index 000000000..07bd66db2 --- /dev/null +++ b/apps/electron-backend-e2e/playwright.compositing.config.ts @@ -0,0 +1,39 @@ +import { workspaceRoot } from '@nx/devkit'; +import { defineConfig } from '@playwright/test'; + +/** + * Compositing report (`pnpm run perf:compositing`, + * docs/architecture/performance-journeys.md#compositing-budget). One worker, + * no retries. The Xtream mock runs on a dedicated loopback port so a normal + * E2E server on 3211 cannot be reused by accident; the test fixtures read the + * port from `XTREAM_MOCK_PORT`, which this config sets for the worker. + */ +const xtreamMockPort = + process.env['IPTVNATOR_COMPOSITING_XTREAM_MOCK_PORT'] ?? '3233'; +process.env['XTREAM_MOCK_PORT'] = xtreamMockPort; + +export default defineConfig({ + fullyParallel: false, + outputDir: '../../dist/test-results/electron-backend-e2e/compositing', + reporter: [['list']], + retries: 0, + testDir: './src/compositing', + testMatch: '**/*.report.ts', + timeout: 15 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + webServer: { + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + cwd: workspaceRoot, + env: { + HOST: '127.0.0.1', + NODE_ENV: 'development', + PORT: xtreamMockPort, + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + reuseExistingServer: !process.env['CI'], + url: `http://127.0.0.1:${xtreamMockPort}/health`, + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 82e200a4f..b78d584df 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -68,6 +68,16 @@ "command": "playwright test --config=playwright.journeys.config.ts" } }, + "compositing-report": { + "dependsOn": ["electron-backend:build-e2e"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "playwright test --config=playwright.compositing.config.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/compositing.e2e.ts b/apps/electron-backend-e2e/src/compositing.e2e.ts new file mode 100644 index 000000000..8206ad00f --- /dev/null +++ b/apps/electron-backend-e2e/src/compositing.e2e.ts @@ -0,0 +1,137 @@ +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + addCurrentDetailToFavorites, + goBackFromDetail, + toggleFavoriteForChannel, +} from './dashboard-e2e-flows'; +import { + collectCompositedLayers, + describeLayerSize, + findOversizedClipMasks, + measureContentArea, + routeDeterministicArtwork, +} from './performance/compositing-probe'; +import { + fetchXtreamLiveFixture, + fetchXtreamSeriesFixture, + fetchXtreamVodFixture, + getXtreamTitle, +} from './portal-mock-fixtures'; + +test.describe('Compositing budget', () => { + // A `border-radius` on the content scroller made Blink clip every + // composited effect inside it (the rail chevrons' and hero controls' + // backdrop filters, running animations) through a synthesized mask layer + // the size of the whole content area: ~24 MB of tile memory each at 2x, + // a dozen on the dashboard, which took the renderer past Chromium's tile + // budget ("tile memory limits exceeded") and left blank tiles while + // scrolling. The corner is painted instead (workspace shell contract). + // Blink synthesizes the masks while layerizing, before any GPU work: + // under `--disable-gpu` (the Linux CI launch, software compositing and + // raster) the controls are still composited and the old radius still + // produced seven masks, so the check guards the same path everywhere. + test('synthesizes no content-area-sized clip masks on the dashboard, the detail pages and Live TV', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const credentials = { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }; + const live = await fetchXtreamLiveFixture(request, credentials); + const vod = await fetchXtreamVodFixture(request, credentials); + const series = await fetchXtreamSeriesFixture(request, credentials); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await routeDeterministicArtwork(page); + const cdp = await app.electronApp.context().newCDPSession(page); + const clipMasks = async () => { + const area = await measureContentArea(page); + const layers = await collectCompositedLayers(cdp, page); + return { + backdropFilterLayers: layers.filter((layer) => + layer.reasons.includes('BackdropFilter') + ).length, + masks: findOversizedClipMasks(layers, area).map( + describeLayerSize + ), + }; + }; + + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await clickCategoryByNameExact(page, live.categoryName); + await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0])); + await expect( + page.getByTestId('channel-item').first() + ).toBeVisible(); + expect((await clipMasks()).masks, 'Live TV').toEqual([]); + + await page + .getByRole('link', { exact: true, name: 'Movies' }) + .click(); + await clickCategoryByNameExact(page, vod.categoryName); + await clickFirstGridListCard(page); + await expect( + page.locator('app-content-hero .hero__backdrop-image') + ).toBeVisible({ timeout: 20_000 }); + await addCurrentDetailToFavorites(page); + const movie = await clipMasks(); + // The detail actions' ghost surfaces are backdrop-filtered. + expect(movie.backdropFilterLayers, 'movie detail').toBeGreaterThan( + 0 + ); + expect(movie.masks, 'movie detail').toEqual([]); + await goBackFromDetail(page); + + await page + .getByRole('link', { exact: true, name: 'Series' }) + .click(); + await clickCategoryByNameExact(page, series.categoryName); + await clickFirstGridListCard(page); + await expect(page.locator('app-content-hero')).toBeVisible({ + timeout: 20_000, + }); + await addCurrentDetailToFavorites(page); + expect((await clipMasks()).masks, 'series detail').toEqual([]); + + await goToDashboard(page); + await expect(page.getByTestId('dashboard-hero')).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByTestId('dashboard-xtream-recently-added-rail') + ).toBeVisible({ timeout: 20_000 }); + const dashboard = await clipMasks(); + // The composited effects this guards: every rail has two + // backdrop-filtered chevrons, the hero its controls. Without this + // the mask check would pass on a page with nothing to clip. + expect(dashboard.backdropFilterLayers, 'dashboard').toBeGreaterThan( + 0 + ); + expect(dashboard.masks, 'dashboard').toEqual([]); + await cdp.detach(); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/compositing/compositing.report.ts b/apps/electron-backend-e2e/src/compositing/compositing.report.ts new file mode 100644 index 000000000..9186f293d --- /dev/null +++ b/apps/electron-backend-e2e/src/compositing/compositing.report.ts @@ -0,0 +1,285 @@ +import type { CDPSession, Page } from '@playwright/test'; + +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + launchElectronApp, + openSettings, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, + workspaceRoot, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { + addCurrentDetailToFavorites, + goBackFromDetail, + toggleFavoriteForChannel, +} from '../dashboard-e2e-flows'; +import { + captureTileMemory, + collectCompositedLayers, + describeLayerOwner, + describeLayerSize, + findOversizedClipMasks, + layerDevicePixels, + layerScale, + measureContentArea, + routeDeterministicArtwork, + TILE_POOL_SETTLE_MS, +} from '../performance/compositing-probe'; +import { + COMPOSITING_SUMMARY_SCHEMA_VERSION, + formatCompositingTable, + resolveCompositingSummaryPath, + writeCompositingSummary, + type CompositingRouteReading, + type CompositingSummary, +} from '../performance/compositing-report'; +import { + fetchXtreamLiveFixture, + fetchXtreamSeriesFixture, + fetchXtreamVodFixture, + getXtreamTitle, +} from '../portal-mock-fixtures'; + +/** + * Compositing report: the renderer's tile memory and composited layers on + * the routes with the most artwork and effects, one fresh profile seeded + * through the app's dialogs against the Xtream mock, at a fixed window size. + * It measures, it does not assert; the deterministic guard is + * `compositing.e2e.ts`. Contract: + * docs/architecture/performance-journeys.md#compositing-budget. + */ +const WINDOW = { height: 1000, width: 1600 }; +const LARGEST_LAYERS = 8; +/** The hero backdrop's zoom transition (`transform 8s` in its stylesheet). */ +const HERO_ZOOM_MS = 8_000; +/** Into the 700 ms crossfade, where the animations are frozen for sampling. */ +const CROSSFADE_SAMPLE_MS = 300; +const TILE_WARNING = /tile memory limits exceeded/; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function setWindowContentSize(app: LaunchedElectronApp): Promise { + await app.electronApp.evaluate(({ BrowserWindow }, size) => { + const [window] = BrowserWindow.getAllWindows(); + window.setPosition(0, 0); + window.setContentSize(size.width, size.height); + }, WINDOW); +} + +async function readGpuCompositing(app: LaunchedElectronApp): Promise { + const status = await app.electronApp.evaluate(({ app: electronApp }) => + electronApp.getGPUFeatureStatus() + ); + return String( + (status as unknown as Record)['gpu_compositing'] + ); +} + +async function readRoute( + cdp: CDPSession, + page: Page, + route: string +): Promise { + const area = await measureContentArea(page); + const layers = await collectCompositedLayers(cdp, page); + const largest = [...layers] + .filter((layer) => layer.drawsContent) + .sort((a, b) => layerDevicePixels(b) - layerDevicePixels(a)) + .slice(0, LARGEST_LAYERS); + const largestLayers = []; + for (const layer of largest) { + largestLayers.push({ + devicePixelsMB: Number( + ((layerDevicePixels(layer) * 4) / 1_048_576).toFixed(1) + ), + height: layer.height, + owner: await describeLayerOwner(cdp, layer), + reasons: layer.reasons, + scale: Number(layerScale(layer).x.toFixed(3)), + width: layer.width, + }); + } + const memory = await captureTileMemory(cdp); + return { + backdropFilterLayers: layers.filter((layer) => + layer.reasons.includes('BackdropFilter') + ).length, + clipMasks: findOversizedClipMasks(layers, area).map(describeLayerSize), + drawingLayers: layers.filter((layer) => layer.drawsContent).length, + imageMB: memory.imageMB, + largestLayers, + route, + tileMB: memory.tileMB, + tileResources: memory.resourceCount, + tileWarnings: 0, + totalLayers: layers.length, + url: new URL(page.url()).pathname.replace(/^.*\/dist\/apps\/web/, ''), + }; +} + +test.describe.configure({ mode: 'serial' }); + +test('compositing report', async ({ dataDir, request }) => { + await resetMockServers(request, ['xtream']); + const credentials = { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }; + const live = await fetchXtreamLiveFixture(request, credentials); + const vod = await fetchXtreamVodFixture(request, credentials); + const series = await fetchXtreamSeriesFixture(request, credentials); + + const app = await launchElectronApp(dataDir, { + env: { ELECTRON_ENABLE_LOGGING: '1' }, + }); + const page = app.mainWindow; + let tileWarnings = 0; + // A chunk can end mid-line: only complete lines are counted, the rest + // waits for the next chunk. + let stderrRemainder = ''; + app.electronApp.process().stderr?.on('data', (chunk: Buffer) => { + const lines = (stderrRemainder + chunk.toString()).split('\n'); + stderrRemainder = lines.pop() ?? ''; + tileWarnings += lines.filter((line) => TILE_WARNING.test(line)).length; + }); + const routes: CompositingRouteReading[] = []; + + try { + await routeDeterministicArtwork(page); + // The same motion on every machine: with the OS set to reduced + // motion the hero neither rotates nor renders its pause button, and + // the crossfade reading would measure a different animation set. + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await setWindowContentSize(app); + await sleep(500); + const cdp = await app.electronApp.context().newCDPSession(page); + let warningsAtLastReading = 0; + const measure = async (route: string, settleMs: number) => { + await sleep(settleMs); + const reading = await readRoute(cdp, page, route); + // One boundary for the delta and the checkpoint, taken after the + // sampling: a warning logged while the layers or the dump were + // read belongs to this route, not to no route. + const warningsNow = tileWarnings; + reading.tileWarnings = warningsNow - warningsAtLastReading; + warningsAtLastReading = warningsNow; + routes.push(reading); + console.log( + `[compositing] ${route}: tile ${reading.tileMB ?? '-'} MB, ${reading.drawingLayers}/${reading.totalLayers} layers, ${reading.clipMasks.length} masks, ${reading.tileWarnings} warnings` + ); + }; + + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await clickCategoryByNameExact(page, live.categoryName); + await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0])); + await measure('live-tv', TILE_POOL_SETTLE_MS); + + await page.getByRole('link', { exact: true, name: 'Movies' }).click(); + await clickCategoryByNameExact(page, vod.categoryName); + await clickFirstGridListCard(page); + await expect( + page.locator('app-content-hero .hero__backdrop-image') + ).toBeVisible({ timeout: 20_000 }); + await measure('movie-detail', TILE_POOL_SETTLE_MS); + await addCurrentDetailToFavorites(page); + await goBackFromDetail(page); + + await page.getByRole('link', { exact: true, name: 'Series' }).click(); + await clickCategoryByNameExact(page, series.categoryName); + await clickFirstGridListCard(page); + await expect(page.locator('app-content-hero')).toBeVisible({ + timeout: 20_000, + }); + await measure('series-detail', TILE_POOL_SETTLE_MS); + await addCurrentDetailToFavorites(page); + + await goToDashboard(page); + await expect(page.getByTestId('dashboard-hero')).toBeVisible({ + timeout: 20_000, + }); + await expect + .poll(() => page.getByTestId('dashboard-hero-dot').count(), { + timeout: 20_000, + }) + .toBeGreaterThanOrEqual(2); + // Idle means no motion: rotation paused (it would advance after 8 s, + // into another slide's zoom), the current slide's zoom finished and + // the tile pool settled. The pointer stays off the hero. + const pause = page.getByTestId('dashboard-hero-pause'); + await pause.click(); + await expect(pause).toHaveAttribute('aria-pressed', 'true'); + await page.mouse.move(1, 1); + await measure('dashboard', HERO_ZOOM_MS + TILE_POOL_SETTLE_MS); + + const dots = page.getByTestId('dashboard-hero-dot'); + const active = await dots.evaluateAll((all) => + all.findIndex((dot) => dot.getAttribute('aria-current') === 'true') + ); + // Deliberately mid-motion: the outgoing and incoming slides are + // both composited while the crossfade and the zoom run. The page's + // animations are frozen at a known point of the 700 ms fade while + // the layers and the memory are read, so the reading does not + // depend on how long the sampling takes. + await dots.nth((active + 1) % (await dots.count())).click(); + await page.mouse.move(1, 1); + await sleep(CROSSFADE_SAMPLE_MS); + await cdp.send('Animation.enable'); + await cdp.send('Animation.setPlaybackRate', { playbackRate: 0 }); + try { + await measure('dashboard-crossfade', 0); + } finally { + await cdp.send('Animation.setPlaybackRate', { playbackRate: 1 }); + await cdp.send('Animation.disable'); + } + // The incoming slide's zoom runs on after the freeze; the scrolled + // reading is taken once it has finished. + await sleep(HERO_ZOOM_MS); + + await page.mouse.move(WINDOW.width / 2, WINDOW.height / 2); + for (let step = 0; step < 8; step += 1) { + await page.mouse.wheel(0, 250); + await sleep(100); + } + await page.mouse.move(1, 1); + await measure('dashboard-scrolled', TILE_POOL_SETTLE_MS); + + await openSettings(page); + await measure('settings', TILE_POOL_SETTLE_MS); + + const summary: CompositingSummary = { + generatedAt: new Date().toISOString(), + harness: { + arch: process.arch, + electronVersion: await app.electronApp.evaluate( + () => process.versions['electron'] ?? 'unknown' + ), + gpuCompositing: await readGpuCompositing(app), + platform: process.platform, + window: { + ...WINDOW, + dpr: await page.evaluate(() => window.devicePixelRatio), + }, + }, + routes, + schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION, + }; + const summaryPath = resolveCompositingSummaryPath(workspaceRoot); + await writeCompositingSummary(summaryPath, summary); + console.log(formatCompositingTable(summary)); + console.log(`[compositing] summary: ${summaryPath}`); + expect(routes.length).toBeGreaterThan(0); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts b/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts deleted file mode 100644 index 156b51319..000000000 --- a/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { CDPSession, Page } from '@playwright/test'; -import { - addXtreamPortal, - closeElectronApp, - expect, - goToDashboard, - launchElectronApp, - resetMockServers, - test, - waitForXtreamWorkspaceReady, -} from './electron-test-fixtures'; - -type CompositedLayer = { - backendNodeId?: number; - drawsContent: boolean; - height: number; - layerId: string; - width: number; -}; - -type CompositedLayerWithReasons = CompositedLayer & { - /** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */ - reasons: string[]; -}; - -/** - * The composited layers of the page with their compositing reasons. Blink - * fills the layer debug info (reasons, owner nodes) in its first - * layerization after the domain is enabled, so the snapshot is retried - * until a layer reports a reason. - */ -async function compositedLayers( - cdp: CDPSession, - page: Page -): Promise { - let layers: CompositedLayer[] = []; - const onChange = (event: { layers?: CompositedLayer[] }) => { - layers = event.layers ?? []; - }; - cdp.on('LayerTree.layerTreeDidChange', onChange); - let withReasons: CompositedLayerWithReasons[] = []; - const snapshot = async () => { - await page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)) - ) - ); - const result: CompositedLayerWithReasons[] = []; - for (const layer of layers) { - let reasons: string[] = []; - try { - const answer = (await cdp.send('LayerTree.compositingReasons', { - layerId: layer.layerId, - })) as { compositingReasonIds?: string[] }; - reasons = answer.compositingReasonIds ?? []; - } catch { - // The layer went away between the snapshot and the query. - } - result.push({ ...layer, reasons }); - } - return result; - }; - try { - await cdp.send('LayerTree.enable'); - await expect - .poll(async () => { - withReasons = await snapshot(); - return withReasons.filter((layer) => layer.reasons.length > 0) - .length; - }) - .toBeGreaterThan(0); - await cdp.send('LayerTree.disable'); - } finally { - cdp.off('LayerTree.layerTreeDidChange', onChange); - } - return withReasons; -} - -test.describe('Dashboard compositing', () => { - // A `border-radius` on the content scroller made Blink clip every - // composited effect inside it (the rail chevrons' and hero controls' - // backdrop filters, running animations) through a synthesized mask layer - // the size of the whole content area: ~24 MB of tile memory each at 2x, - // a dozen on the dashboard, which took the renderer past Chromium's tile - // budget ("tile memory limits exceeded") and left blank tiles while - // scrolling. The corner is painted instead. A mask layer is synthesized, - // not painted for an element: it has no owner node and no compositing - // reason, unlike every content layer (a content layer's owner node can - // also be missing when its first paint chunk belongs to an anonymous - // box, so the node alone does not identify a mask). Blink synthesizes - // them while layerizing, before any GPU work: under `--disable-gpu` (the - // Linux CI launch, software compositing and raster) the controls are - // still composited and the old radius still produced seven masks, so the - // check guards the same path everywhere. - test('does not synthesize content-area-sized clip masks for the composited rail and hero controls', async ({ - dataDir, - request, - }) => { - await resetMockServers(request, ['xtream']); - const app = await launchElectronApp(dataDir); - const page = app.mainWindow; - - try { - await addXtreamPortal(page); - await waitForXtreamWorkspaceReady(page); - await goToDashboard(page); - await expect(page.getByTestId('dashboard-hero')).toBeVisible({ - timeout: 20_000, - }); - await expect( - page.getByTestId('dashboard-xtream-recently-added-rail') - ).toBeVisible({ timeout: 20_000 }); - // The composited effects this guards: every rail has two - // backdrop-filtered chevrons, the hero its controls. - expect(await page.locator('.rail__chev').count()).toBeGreaterThan( - 0 - ); - await expect(page.locator('.hero__controls')).toBeVisible(); - - const content = await page - .locator('main.workspace-content') - .evaluate((element) => ({ - width: element.clientWidth * window.devicePixelRatio, - height: element.clientHeight * window.devicePixelRatio, - })); - const cdp = await app.electronApp.context().newCDPSession(page); - const layers = await compositedLayers(cdp, page); - await cdp.detach(); - - // The effects really are composited here: without this the - // mask check below would pass on a page with nothing to clip. - expect( - layers.filter((layer) => - layer.reasons.includes('BackdropFilter') - ).length - ).toBeGreaterThan(0); - - const contentArea = content.width * content.height; - const clipMasks = layers.filter( - (layer) => - layer.drawsContent && - !layer.backendNodeId && - layer.reasons.length === 0 && - layer.width * layer.height >= contentArea / 2 - ); - expect( - clipMasks.map( - (layer) => - `${layer.layerId}: ${layer.width}x${layer.height}` - ) - ).toEqual([]); - } finally { - await closeElectronApp(app); - } - }); -}); diff --git a/apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts b/apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts new file mode 100644 index 000000000..3f3a0e606 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + describeLayerSize, + findOversizedClipMasks, + isSynthesizedClipMask, + layerDevicePixels, + layerScale, + readTileMemoryFromTrace, + type CompositedLayerWithReasons, + type TraceEvent, +} from './compositing-probe'; + +function layer( + overrides: Partial = {} +): CompositedLayerWithReasons { + return { + drawsContent: true, + height: 1874, + layerId: '317', + reasons: [], + width: 3340, + ...overrides, + }; +} + +/** A column-major 4x4 matrix scaling x and y. */ +function scaleMatrix(x: number, y: number): number[] { + return [x, 0, 0, 0, 0, y, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +} + +const contentArea = { dpr: 2, height: 1874, width: 3340 }; + +test('layerScale reads the x and y scale of a column-major transform', () => { + assert.deepEqual(layerScale(layer()), { x: 1, y: 1 }); + assert.deepEqual( + layerScale(layer({ transform: scaleMatrix(0.96, 0.96) })), + { + x: 0.96, + y: 0.96, + } + ); + // A rotation keeps the axis length. + const rotated = layer({ + transform: [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + }); + assert.deepEqual(layerScale(rotated), { x: 1, y: 1 }); +}); + +test('layerDevicePixels applies the transform scale to the bounds', () => { + assert.equal(layerDevicePixels(layer({ height: 10, width: 10 })), 100); + assert.equal( + Math.round( + layerDevicePixels( + layer({ height: 10, transform: scaleMatrix(0.5, 2), width: 10 }) + ) + ), + 100 + ); +}); + +test('a synthesized clip mask draws content without an owner node or a reason', () => { + assert.equal(isSynthesizedClipMask(layer()), true); + assert.equal(isSynthesizedClipMask(layer({ backendNodeId: 42 })), false); + assert.equal(isSynthesizedClipMask(layer({ reasons: ['Overlap'] })), false); + assert.equal(isSynthesizedClipMask(layer({ drawsContent: false })), false); + // A content layer whose first paint chunk belongs to an anonymous box + // has no owner node but keeps its reason. + assert.equal( + isSynthesizedClipMask( + layer({ backendNodeId: undefined, reasons: ['Overlap'] }) + ), + false + ); +}); + +test('findOversizedClipMasks keeps masks of at least half the content area, at their scale', () => { + const chevronMask = layer({ + height: 1955, + layerId: '330', + transform: scaleMatrix(0.96, 0.96), + width: 3482, + }); + const smallMask = layer({ height: 6, layerId: '400', width: 18 }); + const track = layer({ + backendNodeId: 7, + height: 635, + layerId: '324', + reasons: ['Overlap'], + width: 7520, + }); + const nodelessTrack = layer({ + height: 1874, + layerId: '209', + reasons: ['Overlap'], + width: 3340, + }); + const halfMinusOne = layer({ + height: 1874, + layerId: '500', + width: 1669, + }); + const masks = findOversizedClipMasks( + [layer(), chevronMask, smallMask, track, nodelessTrack, halfMinusOne], + contentArea + ); + assert.deepEqual( + masks.map((mask) => mask.layerId), + ['317', '330'] + ); + assert.deepEqual(masks.map(describeLayerSize), [ + '317: 3340x1874', + '330: 3482x1955', + ]); + assert.deepEqual( + findOversizedClipMasks([halfMinusOne], contentArea, 0.4).map( + (mask) => mask.layerId + ), + ['500'] + ); +}); + +function dump( + pid: number, + allocators: Record +): TraceEvent { + const entries = Object.entries(allocators).map(([name, bytes]) => [ + name, + bytes === null + ? {} + : { + attrs: { + size: { units: 'bytes', value: bytes.toString(16) }, + }, + }, + ]); + return { + args: { dumps: { allocators: Object.fromEntries(entries) } }, + pid, + ph: 'v', + }; +} + +const MB = 1_048_576; + +test('readTileMemoryFromTrace reports the renderer dump with the most tile memory', () => { + const reading = readTileMemoryFromTrace([ + { ph: 'X', pid: 1 }, + dump(1, { 'skia/gpu_resources': 200 * MB }), + dump(2, { + 'cc/image_memory': 118 * MB, + 'cc/tile_memory': 537 * MB, + 'cc/tile_memory/provider_1': null, + 'cc/tile_memory/provider_1/resource_1': 24 * MB, + 'cc/tile_memory/provider_1/resource_2': 24 * MB, + 'cc/tile_memory/provider_1/resource_3': 6.5 * MB, + }), + dump(3, { + 'cc/tile_memory': 12 * MB, + 'cc/tile_memory/provider_2/resource_1': 12 * MB, + }), + ]); + assert.deepEqual(reading, { + imageMB: 118, + largestResourcesMB: [24, 24, 6.5], + pid: 2, + resourceCount: 3, + tileMB: 537, + }); +}); + +test('readTileMemoryFromTrace yields nulls without a tile memory dump', () => { + assert.deepEqual( + readTileMemoryFromTrace([dump(1, { 'skia/gpu_resources': 10 * MB })]), + { + imageMB: null, + largestResourcesMB: [], + pid: null, + resourceCount: 0, + tileMB: null, + } + ); + assert.equal(readTileMemoryFromTrace([]).tileMB, null); +}); + +test('readTileMemoryFromTrace ignores sizes that are not byte counts', () => { + const reading = readTileMemoryFromTrace([ + { + args: { + dumps: { + allocators: { + 'cc/tile_memory': { + attrs: { size: { units: 'objects', value: '10' } }, + }, + 'cc/tile_memory/provider_1/resource_1': { + attrs: { size: { units: 'bytes', value: 'zz' } }, + }, + }, + }, + }, + ph: 'v', + pid: 9, + }, + ]); + assert.equal(reading.tileMB, null); + assert.equal(reading.resourceCount, 0); +}); diff --git a/apps/electron-backend-e2e/src/performance/compositing-probe.ts b/apps/electron-backend-e2e/src/performance/compositing-probe.ts new file mode 100644 index 000000000..94674a00c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-probe.ts @@ -0,0 +1,381 @@ +import type { CDPSession, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import sharp = require('sharp'); + +/** + * Compositing probe: the page's cc layers with their compositing reasons + * and owner nodes (Chrome DevTools Protocol `LayerTree` domain) and the + * renderer's tile memory (`Tracing` with a memory-infra dump). Shared by + * the compositing E2E guard and the `perf:compositing` report. Contract: + * docs/architecture/performance-journeys.md#compositing-budget. + */ + +export interface CompositedLayer { + backendNodeId?: number; + drawsContent: boolean; + height: number; + layerId: string; + paintCount?: number; + /** 4x4 matrix, column major; identity when absent. */ + transform?: number[]; + width: number; +} + +export interface CompositedLayerWithReasons extends CompositedLayer { + /** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */ + reasons: string[]; +} + +/** The clipped area a mask would have to cover, in device pixels. */ +export interface ContentArea { + dpr: number; + height: number; + width: number; +} + +export interface TileMemoryReading { + /** Decoded image memory held by cc (`cc/image_memory`). */ + imageMB: number | null; + /** The largest tile resources, in MB, largest first. */ + largestResourcesMB: number[]; + pid: number | null; + /** Number of tile resources in the renderer's resource pool. */ + resourceCount: number; + /** `cc/tile_memory` of the renderer process, null when no dump had it. */ + tileMB: number | null; +} + +/** + * The resource pool keeps a freed tile for reuse for a few seconds; a + * reading taken sooner after a change still counts the old tiles. + */ +export const TILE_POOL_SETTLE_MS = 6_500; + +/** A layer's x and y scale from its transform (identity when absent). */ +export function layerScale(layer: CompositedLayer): { x: number; y: number } { + const matrix = layer.transform; + if (!matrix || matrix.length < 6) { + return { x: 1, y: 1 }; + } + return { + x: Math.hypot(matrix[0], matrix[1]), + y: Math.hypot(matrix[4], matrix[5]), + }; +} + +/** The device pixels a layer rasterizes at its current scale. */ +export function layerDevicePixels(layer: CompositedLayer): number { + const scale = layerScale(layer); + return layer.width * scale.x * layer.height * scale.y; +} + +/** + * A clip mask Blink synthesized for a rounded clip (or a clip path) around + * a composited effect. It is not painted for any element, so it has no + * owner node and no compositing reason; every content layer carries at + * least one reason, and a content layer can lack an owner node when its + * first paint chunk belongs to an anonymous box, so the node alone does + * not identify a mask. + */ +export function isSynthesizedClipMask( + layer: CompositedLayerWithReasons +): boolean { + return ( + layer.drawsContent && !layer.backendNodeId && layer.reasons.length === 0 + ); +} + +/** + * Synthesized clip masks at least `ratio` of the content area: a rounded + * scroller gives every composited effect inside it one of these, each the + * size of the whole clipped area. + */ +export function findOversizedClipMasks( + layers: readonly CompositedLayerWithReasons[], + area: ContentArea, + ratio = 0.5 +): CompositedLayerWithReasons[] { + const threshold = area.width * area.height * ratio; + return layers.filter( + (layer) => + isSynthesizedClipMask(layer) && + layerDevicePixels(layer) >= threshold + ); +} + +export function describeLayerSize(layer: CompositedLayer): string { + return `${layer.layerId}: ${layer.width}x${layer.height}`; +} + +/** The content scroller's client area in device pixels. */ +export async function measureContentArea( + page: Page, + selector = 'main.workspace-content' +): Promise { + return page.locator(selector).evaluate((element) => ({ + dpr: window.devicePixelRatio, + height: element.clientHeight * window.devicePixelRatio, + width: element.clientWidth * window.devicePixelRatio, + })); +} + +/** + * The composited layers of the page with their compositing reasons. Blink + * fills the layer debug info (reasons, owner nodes) in its first + * layerization after the domain is enabled, so the snapshot is retried + * until a layer reports a reason. Layer bounds are in device pixels. + */ +export async function collectCompositedLayers( + cdp: CDPSession, + page: Page +): Promise { + let layers: CompositedLayer[] = []; + const onChange = (event: { layers?: CompositedLayer[] }) => { + layers = event.layers ?? []; + }; + cdp.on('LayerTree.layerTreeDidChange', onChange); + let withReasons: CompositedLayerWithReasons[] = []; + /** + * Reads every layer's reasons; null when a layer went away between the + * snapshot and its query (the tree changed under us). A vanished layer + * must not be kept with empty reasons: without an owner node it would + * pass for a synthesized mask. + */ + const snapshot = async (): Promise => { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + const result: CompositedLayerWithReasons[] = []; + for (const layer of layers) { + try { + const answer = (await cdp.send('LayerTree.compositingReasons', { + layerId: layer.layerId, + })) as { compositingReasonIds?: string[] }; + result.push({ + ...layer, + reasons: answer.compositingReasonIds ?? [], + }); + } catch { + return null; + } + } + return result; + }; + try { + await cdp.send('LayerTree.enable'); + await expect + .poll(async () => { + const sample = await snapshot(); + withReasons = sample ?? []; + return withReasons.filter((layer) => layer.reasons.length > 0) + .length; + }) + .toBeGreaterThan(0); + await cdp.send('LayerTree.disable'); + } finally { + cdp.off('LayerTree.layerTreeDidChange', onChange); + } + return withReasons; +} + +/** + * `tag#id.class` of the layer's owner node, or `(no owner node)`: a + * synthesized mask has none, and so has a content layer whose first paint + * chunk belongs to an anonymous box. + */ +export async function describeLayerOwner( + cdp: CDPSession, + layer: CompositedLayer +): Promise { + if (!layer.backendNodeId) { + return '(no owner node)'; + } + try { + const described = (await cdp.send('DOM.describeNode', { + backendNodeId: layer.backendNodeId, + })) as { node: { attributes?: string[]; nodeName: string } }; + const attributes = described.node.attributes ?? []; + const idIndex = attributes.indexOf('id'); + const classIndex = attributes.indexOf('class'); + const id = idIndex >= 0 ? `#${attributes[idIndex + 1]}` : ''; + const classes = + classIndex >= 0 + ? attributes[classIndex + 1] + .trim() + .split(/\s+/) + .filter((name) => name && !name.startsWith('ng-')) + .map((name) => `.${name}`) + .join('') + : ''; + return `${described.node.nodeName.toLowerCase()}${id}${classes}`; + } catch { + return `backendNodeId:${layer.backendNodeId}`; + } +} + +interface TraceAllocator { + attrs?: Record; +} + +export interface TraceEvent { + args?: { dumps?: { allocators?: Record } }; + ph?: string; + pid?: number; +} + +function allocatorMB(allocator: TraceAllocator | undefined): number | null { + const size = allocator?.attrs?.['size']; + if (!size || size.units !== 'bytes' || typeof size.value !== 'string') { + return null; + } + const bytes = Number.parseInt(size.value, 16); + return Number.isFinite(bytes) + ? Number((bytes / 1_048_576).toFixed(1)) + : null; +} + +/** + * The renderer's tile memory from memory-infra dump events (`ph: "v"`). + * `cc/tile_memory` is the resource pool's total; its children + * `cc/tile_memory/provider_N/resource_M` are the tiles. Of several + * processes with tile memory the largest is reported. + */ +export function readTileMemoryFromTrace( + events: readonly TraceEvent[] +): TileMemoryReading { + let best: TileMemoryReading = { + imageMB: null, + largestResourcesMB: [], + pid: null, + resourceCount: 0, + tileMB: null, + }; + for (const event of events) { + const allocators = event.args?.dumps?.allocators; + if (event.ph !== 'v' || !allocators?.['cc/tile_memory']) { + continue; + } + const resources: number[] = []; + for (const [name, allocator] of Object.entries(allocators)) { + const parts = name.split('/'); + const isTile = + parts.length === 4 && + parts[0] === 'cc' && + parts[1] === 'tile_memory'; + const size = isTile ? allocatorMB(allocator) : null; + if (size !== null) { + resources.push(size); + } + } + const reading: TileMemoryReading = { + imageMB: allocatorMB(allocators['cc/image_memory']), + largestResourcesMB: resources.sort((a, b) => b - a).slice(0, 6), + pid: event.pid ?? null, + resourceCount: resources.length, + tileMB: allocatorMB(allocators['cc/tile_memory']), + }; + if ((reading.tileMB ?? 0) > (best.tileMB ?? -1)) { + best = reading; + } + } + return best; +} + +async function captureTileMemoryOnce( + cdp: CDPSession +): Promise { + const events: TraceEvent[] = []; + const onData = (event: { value?: TraceEvent[] }) => { + events.push(...(event.value ?? [])); + }; + cdp.on('Tracing.dataCollected', onData); + const complete = new Promise((resolve) => + cdp.once('Tracing.tracingComplete', () => resolve()) + ); + try { + await cdp.send('Tracing.start', { + traceConfig: { + includedCategories: ['disabled-by-default-memory-infra'], + recordMode: 'recordContinuously', + }, + transferMode: 'ReportEvents', + }); + await cdp.send('Tracing.requestMemoryDump', { + levelOfDetail: 'detailed', + }); + // The dump's trace events reach the buffer shortly after the + // request resolves; ending the trace at once can miss them. + await new Promise((resolve) => setTimeout(resolve, 250)); + await cdp.send('Tracing.end'); + await complete; + } finally { + cdp.off('Tracing.dataCollected', onData); + } + return readTileMemoryFromTrace(events); +} + +/** + * One detailed memory-infra dump of the page's renderer. A dump that + * carries no renderer tile memory (a process can skip a dump it is busy + * for) is retried a few times before the reading stays null. + */ +export async function captureTileMemory( + cdp: CDPSession, + attempts = 3 +): Promise { + let reading = await captureTileMemoryOnce(cdp); + for (let attempt = 1; attempt < attempts && reading.tileMB === null;) { + reading = await captureTileMemoryOnce(cdp); + attempt += 1; + } + return reading; +} + +const artworkCache = new Map(); + +/** + * Serves every picsum.photos request of the mock catalogs from memory: an + * opaque gradient of the requested size, so the probe never leaves the + * machine and every image loads (tile and image memory depend on an image + * being there, not on what it shows). + */ +export async function routeDeterministicArtwork(page: Page): Promise { + await page.route( + (url) => url.hostname === 'picsum.photos', + async (route) => { + const match = new URL(route.request().url()).pathname.match( + /\/seed\/([^/]+)\/(\d+)\/(\d+)/ + ); + if (!match) { + await route.fulfill({ body: '', status: 404 }); + return; + } + const [, seed, width, height] = match; + const key = `${seed}-${width}x${height}`; + let body = artworkCache.get(key); + if (body === undefined) { + let hash = 0; + for (const char of seed) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + const r = 60 + (hash % 160); + const g = 60 + ((hash >> 8) % 160); + const b = 60 + ((hash >> 16) % 160); + const svg = ``; + const generated: Buffer = await sharp(Buffer.from(svg)) + .png() + .toBuffer(); + artworkCache.set(key, generated); + body = generated; + } + await route.fulfill({ + body, + contentType: 'image/png', + status: 200, + }); + } + ); +} diff --git a/apps/electron-backend-e2e/src/performance/compositing-report.spec.ts b/apps/electron-backend-e2e/src/performance/compositing-report.spec.ts new file mode 100644 index 000000000..1ff5897db --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-report.spec.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + COMPOSITING_SUMMARY_SCHEMA_VERSION, + formatCompositingTable, + resolveCompositingSummaryPath, + writeCompositingSummary, + type CompositingSummary, +} from './compositing-report'; + +function summary(): CompositingSummary { + return { + generatedAt: '2026-10-09T10:00:00.000Z', + harness: { + arch: 'arm64', + electronVersion: '43.3.0', + gpuCompositing: 'enabled', + platform: 'darwin', + window: { dpr: 2, height: 1000, width: 1600 }, + }, + routes: [ + { + backdropFilterLayers: 9, + clipMasks: [], + drawingLayers: 35, + imageMB: 117.4, + largestLayers: [ + { + devicePixelsMB: 38.9, + height: 3060, + owner: 'main.workspace-content', + reasons: ['Overlap'], + scale: 1, + width: 3336, + }, + ], + route: 'dashboard', + tileMB: 241.6, + tileResources: 51, + tileWarnings: 0, + totalLayers: 43, + url: '/workspace/dashboard', + }, + { + backdropFilterLayers: 0, + clipMasks: ['330: 3482x1955'], + drawingLayers: 46, + imageMB: null, + largestLayers: [], + route: 'settings', + tileMB: null, + tileResources: 0, + tileWarnings: 7, + totalLayers: 54, + url: '/workspace/settings', + }, + ], + schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION, + }; +} + +test('resolveCompositingSummaryPath puts one run under dist/performance/compositing', () => { + assert.equal( + resolveCompositingSummaryPath( + '/repo', + new Date('2026-10-09T10:11:12.345Z') + ), + join( + '/repo', + 'dist', + 'performance', + 'compositing', + '20261009T101112Z', + 'summary.json' + ) + ); +}); + +test('writeCompositingSummary writes the JSON once and never overwrites it', async () => { + const directory = await mkdtemp(join(tmpdir(), 'compositing-summary-')); + try { + const path = join(directory, 'nested', 'summary.json'); + await writeCompositingSummary(path, summary()); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), summary()); + await assert.rejects(writeCompositingSummary(path, summary()), { + code: 'EEXIST', + }); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('formatCompositingTable lists every route with its memory, masks and warnings', () => { + const table = formatCompositingTable(summary()); + const lines = table.split('\n'); + assert.match( + lines[0], + /1600x1000 @2x, gpu compositing enabled, Electron 43\.3\.0/ + ); + assert.match( + lines[1], + /route\s+tile MB\s+image MB\s+layers\s+backdrop\s+masks\s+warn/ + ); + const dashboard = lines.find((line) => line.startsWith('dashboard')); + assert.ok(dashboard); + assert.match(dashboard, /241\.6\s+117\.4\s+35\/43\s+9\s+0\s+0$/); + assert.match( + lines[lines.indexOf(dashboard) + 1], + /38\.9 MB 3336x3060 s=1 main\.workspace-content \[Overlap\]/ + ); + const settings = lines.find((line) => line.startsWith('settings')); + assert.ok(settings); + assert.match(settings, /-\s+-\s+46\/54\s+0\s+1\s+7$/); +}); diff --git a/apps/electron-backend-e2e/src/performance/compositing-report.ts b/apps/electron-backend-e2e/src/performance/compositing-report.ts new file mode 100644 index 000000000..692a978cd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-report.ts @@ -0,0 +1,103 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { formatJourneyOutputTimestamp } from './journey-summary'; + +/** + * Summary written by `pnpm run perf:compositing` + * (`src/compositing/compositing.report.ts`): one reading per route, with + * the renderer's tile memory and the largest composited layers. Contract: + * docs/architecture/performance-journeys.md#compositing-budget. + */ +export const COMPOSITING_SUMMARY_SCHEMA_VERSION = 1; + +export interface CompositingLayerSummary { + /** Device pixels the layer rasterizes at its scale, in MB of RGBA. */ + devicePixelsMB: number; + height: number; + /** `tag#id.class` of the owner node, or `(no owner node)`. */ + owner: string; + reasons: string[]; + scale: number; + width: number; +} + +export interface CompositingRouteReading { + /** Composited layers whose reasons include `BackdropFilter`. */ + backdropFilterLayers: number; + /** Synthesized clip masks at least half the content area (`id: WxH`). */ + clipMasks: string[]; + drawingLayers: number; + imageMB: number | null; + largestLayers: CompositingLayerSummary[]; + route: string; + tileMB: number | null; + tileResources: number; + /** "tile memory limits exceeded" lines on stderr while on the route. */ + tileWarnings: number; + totalLayers: number; + url: string; +} + +export interface CompositingSummary { + generatedAt: string; + harness: { + arch: string; + electronVersion: string; + /** `gpu_compositing` from `app.getGPUFeatureStatus()`. */ + gpuCompositing: string; + platform: string; + window: { dpr: number; height: number; width: number }; + }; + routes: CompositingRouteReading[]; + schemaVersion: number; +} + +export function resolveCompositingSummaryPath( + repositoryRoot: string, + date: Date = new Date() +): string { + return join( + repositoryRoot, + 'dist', + 'performance', + 'compositing', + formatJourneyOutputTimestamp(date), + 'summary.json' + ); +} + +/** Writes the summary; an existing file is never overwritten. */ +export async function writeCompositingSummary( + summaryPath: string, + summary: CompositingSummary +): Promise { + await mkdir(dirname(summaryPath), { recursive: true }); + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { + flag: 'wx', + }); +} + +function pad(value: string | number, width: number): string { + return String(value).padStart(width); +} + +/** A fixed-width table of the routes for the console. */ +export function formatCompositingTable(summary: CompositingSummary): string { + const { window } = summary.harness; + const lines = [ + `compositing budget: ${window.width}x${window.height} @${window.dpr}x, gpu compositing ${summary.harness.gpuCompositing}, Electron ${summary.harness.electronVersion}`, + `${'route'.padEnd(20)} ${pad('tile MB', 8)} ${pad('image MB', 9)} ${pad('layers', 7)} ${pad('backdrop', 9)} ${pad('masks', 6)} ${pad('warn', 5)}`, + ]; + for (const route of summary.routes) { + lines.push( + `${route.route.padEnd(20)} ${pad(route.tileMB ?? '-', 8)} ${pad(route.imageMB ?? '-', 9)} ${pad(`${route.drawingLayers}/${route.totalLayers}`, 7)} ${pad(route.backdropFilterLayers, 9)} ${pad(route.clipMasks.length, 6)} ${pad(route.tileWarnings, 5)}` + ); + for (const layer of route.largestLayers) { + lines.push( + ` ${pad(layer.devicePixelsMB.toFixed(1), 7)} MB ${layer.width}x${layer.height} s=${layer.scale} ${layer.owner} [${layer.reasons.join(',')}]` + ); + } + } + return lines.join('\n'); +} diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts index 740a1ee5c..798989e6a 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -30,6 +30,9 @@ describe('Xtream mock Nx serve environment', () => { describe('Playwright mock-server launch', () => { const appsDirectory = join(process.cwd(), 'apps'); const expectedMockLaunches: Record = { + 'apps/electron-backend-e2e/playwright.compositing.config.ts': [ + 'xtream', + ], 'apps/electron-backend-e2e/playwright.config.ts': ['stalker', 'xtream'], 'apps/electron-backend-e2e/playwright.journeys.config.ts': ['xtream'], 'apps/electron-backend-e2e/playwright.xtream-performance.config.ts': [ diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 4312a6aae..34ddc0661 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1392,6 +1392,68 @@ threshold on Node 22 and inside Electron 43, so D2 regex prefilters were not applied. Rerun the benchmark after changing either parser or when a user reports slow imports of non-Latin playlists. +## Compositing budget + +Chromium rasterizes every composited layer into tiles and gives a desktop +renderer 512 MB of tile memory (1 GB on a display at least 3500 device pixels +wide). Tiles needed for the visible frame that do not fit log +`cc/tiles/tile_manager.cc: WARNING: tile memory limits exceeded, some content +may not draw` and leave blank tiles while scrolling. On a 2x display a layer +the size of the content area costs about 24 MB, so a dozen full-size layers +spend the budget. The expensive layers are rarely the obvious ones: a +`border-radius` on a scroller makes Blink clip every composited effect inside +it (`backdrop-filter`, `filter` with a render surface, a running transform or +opacity animation, `isolation: isolate`, `will-change`) through a mask layer +synthesized per effect, each the size of the whole clipped area, because its +shader path for rounded clips needs four equal radii on macOS and a +translation-only transform between the clip and the effect +(`PropertyTreeManager::ShaderBasedRRect`). The workspace shell therefore +paints its content corner instead of clipping it +([workspace shell](workspace-shell.md#content-surface-corner)). + +Two instruments share `src/performance/compositing-probe.ts`, which reads +the layer tree over the Chrome DevTools Protocol (`LayerTree`: bounds in +device pixels, compositing reasons and owner nodes, available only after +Blink's first layerization with the domain enabled) and the renderer's +`cc/tile_memory` from a memory-infra dump (`Tracing.requestMemoryDump`). A +synthesized clip mask is a drawing layer with no owner node and no +compositing reason; a content layer always carries a reason, and may lack an +owner node when its first paint chunk belongs to an anonymous box. + +- `src/compositing.e2e.ts` is the deterministic guard, run with the Electron + E2E suite on every platform: on Live TV, a movie detail, a series detail + and the dashboard no synthesized mask may span half the content area, and + the backdrop-filtered controls must really be composited layers, so the + check cannot pass on a page with nothing to clip. Blink synthesizes the + masks before any GPU work; under `--disable-gpu` (the Linux CI launch) the + old scroller radius still produced seven. +- `pnpm run perf:compositing` (`electron-backend-e2e:compositing-report`, + `playwright.compositing.config.ts`, Xtream mock on `127.0.0.1:3233`, + override with `IPTVNATOR_COMPOSITING_XTREAM_MOCK_PORT`) measures. One fresh + profile is seeded through the app's dialogs with a live, a movie and a + series favourite, artwork is served from memory, and the window is 1600x1000 + CSS pixels. For Live TV, the movie and series details, the dashboard (idle, + during a crossfade, scrolled) and settings it records tile and image + memory, composited layer counts, the eight largest layers with their + owners and reasons, synthesized masks and the `tile memory limits + exceeded` lines on stderr, into + `dist/performance/compositing//summary.json` and a table on + the console. The pool keeps freed tiles for a few seconds, so each reading + waits `TILE_POOL_SETTLE_MS` after its route settles. + +Megabytes depend on the window size, the device scale factor and the GPU +path, so they are evidence for a PR, not a ratchet: the Linux runner at 1x +cannot reproduce a 2x laptop. Layer and mask counts on the fixed mock are +comparable across machines; wiring them into the [ratchet](#ratchet) needs a +baseline measured on the canonical runner first. A PR that adds or changes +`backdrop-filter`, `filter`, `will-change`, `isolation`, a transform or +opacity animation, a `mask` or `clip-path`, or a `border-radius` on an +element with `overflow`, runs the report before and after and quotes the +affected routes. Reference, 2026-10-09 on a 16" MacBook Pro at 2x before and +after the painted corner: dashboard idle 537 to 242 MB, during a slide switch +598 to 303 MB, scrolled 655 to 165 MB; movie detail 110 to 89 MB; series +detail 187 to 143 MB. + ## Adding a counter 1. Produce the value from the built output or from a deterministic probe, not diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 4ee3ee303..4d8520253 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -277,6 +277,7 @@ pnpm run perf:initial-bytes # breakdown only pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline pnpm nx test performance-tools pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys//summary.json +pnpm run perf:compositing # tile memory and composited layers per route, dist/performance/compositing//summary.json ``` `perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the @@ -306,7 +307,10 @@ not through `pnpm exec`: under `pnpm nx`, a nested `pnpm exec` can run from the workspace root instead of the target `cwd` and miss cwd-relative specs, globs and configs. The contract, what counts and how to add a counter or a journey are in the -[performance journeys](performance-journeys.md) document. +[performance journeys](performance-journeys.md) document. `perf:compositing` +measures tile memory and composited layers on the artwork-heavy routes; the +deterministic guard is `compositing.e2e.ts` in the Electron E2E suite +([compositing budget](performance-journeys.md#compositing-budget)). ## Logging diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 88045070a..e7f7239b5 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -121,7 +121,7 @@ translation-only transform between the clip and the effect, and the fallback mask is the size of the whole clipped area, about 24 MB of tile memory at 2x on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB tile budget (`tile memory limits exceeded`, blank tiles while scrolling). -`apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts` guards it: no +`apps/electron-backend-e2e/src/compositing.e2e.ts` guards it: no drawing layer without an owner node and without a compositing reason (a synthesized mask, unlike any content layer) may span half the content area, and the backdrop-filtered controls must really be composited, so the check diff --git a/docs/maintenance/agent-context-map.md b/docs/maintenance/agent-context-map.md index 41b6ba28c..6dd6bc085 100644 --- a/docs/maintenance/agent-context-map.md +++ b/docs/maintenance/agent-context-map.md @@ -38,6 +38,7 @@ are not prerequisites for reading repository contracts. | Web engines, controls, tracks, PiP, radio and display sleep; `libs/ui/playback` | [Player controls](../architecture/player-controls-contract.md), [inline playback/diagnostics/recovery](../architecture/embedded-inline-playback.md) | Read the contract directly | | Embedded MPV, platform engines, addon, pinned runtime and packaging; Electron native services, `tools/embedded-mpv` | [Native MPV](../architecture/embedded-mpv-native.md), [runtime build and licensing](../../tools/embedded-mpv/README.md) | Read the contract directly | | Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) | +| Rendering cost of UI: `backdrop-filter`, `filter`, `will-change`, `isolation`, transform/opacity animations, rounded or path-clipped scrollers; tile memory | [Compositing budget](../architecture/performance-journeys.md#compositing-budget), [workspace shell](../architecture/workspace-shell.md#content-surface-corner) | [Theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) | | Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes | | Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes | | Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill | diff --git a/package.json b/package.json index f7698f44c..710f87f85 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs", "perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes", "perf:journeys": "nx run electron-backend-e2e:journeys", + "perf:compositing": "nx run electron-backend-e2e:compositing-report", "perf:ratchet:check": "node tools/performance/check-journey-ratchet.mjs --summary dist/performance/journey-summary.json", "perf:tools:test": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs tools/performance/tighten-baselines.test.mjs", "agents:validate": "node tools/skills/validate-agent-guidance.mjs",