A still editor has nothing to draw, so the frame loop wakes on change (#44)
game-maker / suite (push) Successful in 10m46s

The renderer is taken off the ticker; the ticker itself keeps running, so
the clock, a live world and the panels still get their step. Drawing now
happens inside a 200 ms trailing window that every pointer, wheel and key
event, every Widget.invalidate/resize, every Stack.relayout, every
EditorState.emit and both library onChange hooks reopen. The window rather
than a single frame is what makes the ~70 direct redraw() calls that never
pass through invalidate() safe, along with repaints an event only starts:
a microtask, a texture upload, a resolved font.

Editor.movesByItself() keeps asking for every frame while Play or an editor
run is on, a time shader is on screen, or the particle or animation preview
is showing.

The FPS counter now counts drawn frames over the time the renderer was
awake. Idle seconds are not slow seconds, so it answers "how smooth while
it moves" and holds its last answer instead of falling to 0 at rest.

Measured on the built stand, 20 s per variant, CPU of all chromium
processes from /proc: kept awake 75.5 % of a core at 60 fps, render on
change 8.8 % with 0 frames drawn, awake again 83.9 % as an order probe.

No colour check can see a missing repaint: pixelAt extracts from the stage
on demand and is just as green on a screen frozen a minute ago. Step 33
watches the new __gm.renderStats() counter instead.
This commit is contained in:
admin committed 2026-09-14 21:41:50 +02:00
1 parent 2637feb715
commit bc57f61c91
16 files changed
+355 -7

No files matched your search

+5 -1
View File
@@ -2,7 +2,7 @@ import type { Application } from 'pixi.js';
import { HttpFontStore } from '../core/store/HttpFontStore'; import { HttpFontStore } from '../core/store/HttpFontStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpTextureStore } from '../core/store/HttpTextureStore'; import { HttpTextureStore } from '../core/store/HttpTextureStore';
import type { VStack } from '../ui'; import { requestRender, type VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary'; import { fontLibrary } from '../viewport/FontLibrary';
import { textureLibrary } from '../viewport/TextureLibrary'; import { textureLibrary } from '../viewport/TextureLibrary';
import { installDebugHook } from './debugHook'; import { installDebugHook } from './debugHook';
@@ -31,6 +31,10 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpTextureStore(API_ENDPOINT), new HttpTextureStore(API_ENDPOINT),
new HttpFontStore(API_ENDPOINT), new HttpFontStore(API_ENDPOINT),
); );
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own.
textureLibrary.onChange(() => requestRender());
fontLibrary.onChange(() => requestRender());
installDebugHook(app, state, editor); installDebugHook(app, state, editor);
await editor.boot(); await editor.boot();
} }
+40 -4
View File
@@ -5,7 +5,19 @@ import type { ProjectStore } from '../core/store/ProjectStore';
import type { FontStore } from '../core/store/FontStore'; import type { FontStore } from '../core/store/FontStore';
import type { TextureStore } from '../core/store/TextureStore'; import type { TextureStore } from '../core/store/TextureStore';
import { gameClock } from '../core/time'; import { gameClock } from '../core/time';
import { clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui'; import {
clamp,
HStack,
isRendering,
Panel,
renderCount,
requestRender,
Splitter,
Theme,
ui,
VStack,
type Widget,
} from '../ui';
import { TabStrip, type TabPlace } from './TabStrip'; import { TabStrip, type TabPlace } from './TabStrip';
import { ConsolePanel } from './console/ConsolePanel'; import { ConsolePanel } from './console/ConsolePanel';
import { GameViewport } from './viewport/GameViewport'; import { GameViewport } from './viewport/GameViewport';
@@ -125,6 +137,7 @@ export class Editor implements PlayHost {
private readonly tabs: TabStrip<CentreTab>; private readonly tabs: TabStrip<CentreTab>;
private readonly panes: Record<CentreTab, Widget>; private readonly panes: Record<CentreTab, Widget>;
private readonly toolbar: Toolbar; private readonly toolbar: Toolbar;
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0; private fpsFrames = 0;
private fpsElapsed = 0; private fpsElapsed = 0;
private leftWidth = 250; private leftWidth = 250;
@@ -331,14 +344,37 @@ export class Editor implements PlayHost {
this.console.refresh(); this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(step); if (this.tab === 'animation') this.animationEditor.tick(step);
if (this.tab === 'particles') this.particlesEditor.tick(); if (this.tab === 'particles') this.particlesEditor.tick();
if (this.movesByItself()) requestRender();
} }
/**
* What changes without anyone touching it, and therefore cannot be woken by an event: the
* running world, a Time shader on screen, the two previews that are moving pictures.
*/
private movesByItself(): boolean {
return (
this.play.isPlaying ||
this.editorRun.isRunning ||
(this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' ||
(this.tab === 'animation' && this.animationEditor.isPlaying)
);
}
/**
* Counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds:
* since the editor stopped drawing what nobody changed, counting them would drag the readout to
* zero while everything is fine. It answers "how smooth is it while it moves", and holds its
* last answer in between.
*/
private measureFps(dt: number): void { private measureFps(dt: number): void {
this.fpsFrames += 1; if (!isRendering()) return;
this.fpsElapsed += dt; this.fpsElapsed += dt;
if (this.fpsElapsed < 0.5) return; if (this.fpsElapsed < 0.5) return;
this.fps = Math.round(this.fpsFrames / this.fpsElapsed); const drawn = renderCount();
this.fpsFrames = 0; this.fps = Math.round((drawn - this.fpsFrames) / this.fpsElapsed);
this.fpsFrames = drawn;
this.fpsElapsed = 0; this.fpsElapsed = 0;
this.toolbar.setFps(this.fps); this.toolbar.setFps(this.fps);
} }
+5
View File
@@ -80,6 +80,11 @@ export class EditorRun {
return findAsset(this.state.project, selection.assetId)?.type === 'variables'; return findAsset(this.state.project, selection.assetId)?.type === 'variables';
} }
/** Whether a world is up – what tells the editor its picture moves on its own. */
get isRunning(): boolean {
return this.world !== null;
}
/** Facts for the check scripts: object ids of the behaviors currently running without Play. */ /** Facts for the check scripts: object ids of the behaviors currently running without Play. */
debugInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } { debugInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } {
return { return {
+5
View File
@@ -27,6 +27,11 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, AnimationCan
this.canvas.tick(dt); this.canvas.tick(dt);
} }
/** Whether the preview is running – a moving picture needs a frame without an event. */
get isPlaying(): boolean {
return this.canvas.playing;
}
protected override selectedId(): string { protected override selectedId(): string {
return this.state.selectedAnimationId ?? ''; return this.state.selectedAnimationId ?? '';
} }
+9 -1
View File
@@ -4,7 +4,7 @@ import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project'; import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture'; import type { FrameRect } from '../core/model/texture';
import { effectiveScale, gameClock } from '../core/time'; import { effectiveScale, gameClock } from '../core/time';
import { activeDrag, Button, ui, Widget } from '../ui'; import { activeDrag, Button, isRendering, renderCount, requestRender, ui, Widget } from '../ui';
import { particleLibrary } from '../viewport/ParticleLibrary'; import { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary'; import { shaderLibrary } from '../viewport/ShaderLibrary';
import { textureLibrary } from '../viewport/TextureLibrary'; import { textureLibrary } from '../viewport/TextureLibrary';
@@ -450,6 +450,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
}; };
}, },
fps: () => editor.fps, fps: () => editor.fps,
/**
* What the render loop did: frames actually drawn, and whether it is currently awake. The
* only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and
* would answer correctly even if the screen had been frozen for a minute.
*/
renderStats: () => ({ drawn: renderCount(), awake: isRendering() }),
/** Draw again, whatever happens next – what a screenshot needs before it is taken. */
wake: () => requestRender(),
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */ /** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
filterCount: () => { filterCount: () => {
let n = 0; let n = 0;
+4
View File
@@ -13,6 +13,7 @@ import {
type Project, type Project,
} from '../core/model/Project'; } from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize'; import { deserializeProject, serializeProject } from '../core/store/serialize';
import { requestRender } from '../ui';
import { consoleLog } from './console/log'; import { consoleLog } from './console/log';
export type Selection = export type Selection =
@@ -128,6 +129,9 @@ export class EditorState {
} }
emit(event: EditorEvent): void { emit(event: EditorEvent): void {
// The one choke point every model change passes: what a listener repaints is drawn even when
// it repaints without going through `invalidate()`.
requestRender();
for (const listener of this.listeners.get(event) ?? []) listener(); for (const listener of this.listeners.get(event) ?? []) listener();
} }
+3 -1
View File
@@ -5,7 +5,7 @@ import { Application, Container } from 'pixi.js';
import { captureConsole } from './editor/console/log'; import { captureConsole } from './editor/console/log';
import { startEditor } from './editor/App'; import { startEditor } from './editor/App';
import { mountPlayground } from './playground'; import { mountPlayground } from './playground';
import { initUI, Theme, VStack } from './ui'; import { initRenderLoop, initUI, Theme, VStack } from './ui';
// Before anything else: a renderer warns while it is being created, and those warnings are // Before anything else: a renderer warns while it is being created, and those warnings are
// exactly the ones worth having in the panel. // exactly the ones worth having in the panel.
@@ -45,6 +45,8 @@ async function main(): Promise<void> {
app.stage.eventMode = 'static'; app.stage.eventMode = 'static';
app.stage.hitArea = app.screen; app.stage.hitArea = app.screen;
initUI(app, root, overlay); initUI(app, root, overlay);
// From here on the picture is drawn when something changes, not sixty times a second.
initRenderLoop(app);
if (params.has('playground')) mountPlayground(app, root); if (params.has('playground')) mountPlayground(app, root);
else await startEditor(app, root); else await startEditor(app, root);
+10
View File
@@ -1,6 +1,7 @@
import type { Application, Container } from 'pixi.js'; import type { Application, Container } from 'pixi.js';
import { FocusManager } from './FocusManager'; import { FocusManager } from './FocusManager';
import { KeyboardBridge } from './KeyboardBridge'; import { KeyboardBridge } from './KeyboardBridge';
import { requestRender } from './render';
/** Services shared by all widgets, created once by `initUI()`. */ /** Services shared by all widgets, created once by `initUI()`. */
export interface UIContext { export interface UIContext {
@@ -22,6 +23,15 @@ export function initUI(app: Application, root: Container, overlay: Container): U
// Clicking the canvas must not steal DOM focus from the keyboard bridge input. // Clicking the canvas must not steal DOM focus from the keyboard bridge input.
app.canvas.addEventListener('mousedown', (e) => e.preventDefault()); app.canvas.addEventListener('mousedown', (e) => e.preventDefault());
app.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); app.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Anything the user does may change the picture, and in far more places than the ones that
// remember to invalidate: one wake-up at the door covers all of them (see `render.ts`).
// Listened for on the window rather than on the stage: a release or a key that arrives while
// the pointer sits outside the canvas still ends a drag or an edit.
const wake = () => requestRender();
for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'wheel'] as const)
window.addEventListener(type, wake, { capture: true, passive: true });
for (const type of ['keydown', 'keyup', 'focus', 'blur'] as const)
window.addEventListener(type, wake, true);
return context; return context;
} }
+3
View File
@@ -1,4 +1,5 @@
import { Container, Rectangle } from 'pixi.js'; import { Container, Rectangle } from 'pixi.js';
import { requestRender } from './render';
export interface Size { export interface Size {
w: number; w: number;
@@ -44,6 +45,7 @@ export abstract class Widget extends Container {
this.hitArea = new Rectangle(0, 0, w, h); this.hitArea = new Rectangle(0, 0, w, h);
if (changed) this.layout(); if (changed) this.layout();
this.redraw(); this.redraw();
requestRender();
} }
/** Position children. Called after a resize; must be cheap and idempotent. */ /** Position children. Called after a resize; must be cheap and idempotent. */
@@ -53,6 +55,7 @@ export abstract class Widget extends Container {
/** Request a repaint at the end of the current frame (coalesced). */ /** Request a repaint at the end of the current frame (coalesced). */
invalidate(): void { invalidate(): void {
requestRender();
if (this.dirty) return; if (this.dirty) return;
this.dirty = true; this.dirty = true;
queueMicrotask(() => { queueMicrotask(() => {
+1
View File
@@ -2,6 +2,7 @@ export { Widget, Box, type Size } from './Widget';
export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack'; export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
export { Theme, textStyle, type TextStyleToken } from './Theme'; export { Theme, textStyle, type TextStyleToken } from './Theme';
export { initUI, ui, type UIContext } from './UIContext'; export { initUI, ui, type UIContext } from './UIContext';
export { initRenderLoop, isRendering, renderCount, requestRender, resetRenderLoop } from './render';
export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
+3
View File
@@ -1,3 +1,4 @@
import { requestRender } from '../render';
import { type Size, Widget } from '../Widget'; import { type Size, Widget } from '../Widget';
export interface StackOptions { export interface StackOptions {
@@ -92,6 +93,8 @@ export abstract class Stack extends Widget {
relayout(): void { relayout(): void {
this.layout(); this.layout();
this.redraw(); this.redraw();
// A stack that only moves children around resizes none of them, so this is its own wake-up.
requestRender();
} }
} }
+63
View File
@@ -0,0 +1,63 @@
import { type Application, UPDATE_PRIORITY } from 'pixi.js';
/**
* Drives the renderer from changes instead of from the clock.
*
* An idle editor at 60 fps costs ~75 % of a core, and ~64 of those points are the drawing alone –
* a picture nobody asked to change. The ticker keeps running (~3 points): the clock, a running
* world and the panels still get their frame, only `app.render()` is skipped while nothing asked
* for it.
*
* What wakes it is deliberately not a strict dirty flag. Every pointer and keyboard event, every
* `Widget.invalidate()`/`resize()`, every `EditorState.emit()` and every texture or font arriving
* opens a short trailing window rather than requesting exactly one frame. A repaint that lands a
* few milliseconds after its cause – a microtask, a texture upload, a font resolving – is drawn
* without anyone having had to declare it, which is the failure this design refuses to have:
* a missing repaint is invisible to the check scripts, because `pixelAt` re-renders on demand.
*/
/** How long one wake-up keeps drawing. Long enough for the late repaint, short enough to idle. */
const WAKE_MS = 200;
let deadline = 0;
let rendered = 0;
/** Draw, and keep drawing for a moment. Cheap enough to call from every event. */
export function requestRender(): void {
deadline = performance.now() + WAKE_MS;
}
/** Whether the renderer is currently awake. */
export function isRendering(): boolean {
return performance.now() <= deadline;
}
/** Frames actually drawn since the loop was installed – the fps readout and the checks read it. */
export function renderCount(): number {
return rendered;
}
/**
* Takes the renderer off the ticker and drives it from `requestRender()` instead. Added at the
* ticker's lowest priority, so everything that may request a frame has run for this tick already.
*/
export function initRenderLoop(app: Application): void {
// `TickerPlugin` registers `Application.prototype.render` bound to the app; same pair to remove.
app.ticker.remove(app.render, app);
app.ticker.add(
() => {
if (!isRendering()) return;
rendered += 1;
app.render();
},
undefined,
UPDATE_PRIORITY.LOW,
);
requestRender();
}
/** Tests only: back to a cold loop. */
export function resetRenderLoop(): void {
deadline = 0;
rendered = 0;
}
+96
View File
@@ -0,0 +1,96 @@
import type { Application } from 'pixi.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
initRenderLoop,
isRendering,
renderCount,
requestRender,
resetRenderLoop,
} from '../../src/ui/render';
/** A stand-in for the Pixi application: counts draws and remembers what was put on the ticker. */
function fakeApp() {
const added: { fn: () => void; priority?: number }[] = [];
const removed: { fn: unknown; context: unknown }[] = [];
const app = {
drawn: 0,
render(): void {
app.drawn += 1;
},
ticker: {
add(fn: () => void, _context?: unknown, priority?: number) {
added.push({ fn, priority });
return this;
},
remove(fn: unknown, context?: unknown) {
removed.push({ fn, context });
return this;
},
},
};
return { app, added, removed, tick: () => added.forEach((entry) => entry.fn()) };
}
describe('render loop', () => {
let now = 0;
beforeEach(() => {
now = 1000;
vi.spyOn(performance, 'now').mockImplementation(() => now);
resetRenderLoop();
});
afterEach(() => vi.restoreAllMocks());
it('is asleep until something asks for a frame', () => {
expect(isRendering()).toBe(false);
});
it('stays awake for a window after the request, not for a single frame', () => {
requestRender();
expect(isRendering()).toBe(true);
now += 199;
expect(isRendering()).toBe(true);
now += 2;
expect(isRendering()).toBe(false);
});
it('a later request extends the window rather than queueing a second one', () => {
requestRender();
now += 150;
requestRender();
now += 150;
expect(isRendering()).toBe(true);
});
it('takes the renderer off the ticker and drives it itself', () => {
const { app, removed, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
expect(removed).toEqual([{ fn: app.render, context: app }]);
// `initRenderLoop` opens the first window, so the first frames are drawn.
tick();
tick();
expect(app.drawn).toBe(2);
expect(renderCount()).toBe(2);
});
it('draws nothing while nothing changed, and again after a request', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
for (let i = 0; i < 30; i++) tick();
expect(app.drawn).toBe(0);
expect(renderCount()).toBe(0);
requestRender();
tick();
expect(app.drawn).toBe(1);
// And it falls asleep again by itself.
now += 500;
tick();
expect(app.drawn).toBe(1);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { openTab } from './lib/scaffold.mjs';
/**
* Render on change: the editor draws what changed, not sixty pictures a second.
*
* This is the one thing the rest of the suite cannot see. `pixelAt` re-renders the stage on
* demand, so every colour check would pass just as well on a screen that froze a minute ago –
* only the drawn-frame counter tells the difference between "drawn" and "merely correct".
*
* The step runs last and leaves the editor as it found it.
*/
export async function renderOnChange(t) {
const { check, sleep, evaluate, select, click, locate, play, stop, shot } = t;
const stats = () => evaluate('__gm.renderStats()');
/** Frames drawn over `ms` of doing nothing at all. */
const drawnDuring = async (ms) => {
const before = await stats();
await sleep(ms);
const after = await stats();
return after.drawn - before.drawn;
};
/**
* Waits for the trailing window of the last event to run out. Measuring without this counts the
* frames the previous click is still entitled to and calls them a leak.
*/
const settle = async (timeoutMs = 3000) => {
for (let waited = 0; waited < timeoutMs; waited += 100) {
if (!(await stats()).awake) return true;
await sleep(100);
}
return false;
};
// 33a. A tab whose picture stands still: the scene viewport may wear a Time shader, and the
// particles and animation previews are moving pictures by definition.
await openTab(t, 'graph');
await select(null);
const quiet = await settle();
const idle = await drawnDuring(700);
check(
'an editor nobody touches draws no frames at all',
quiet && idle === 0,
`settled=${quiet}, ${idle} frames drawn`,
);
// 33b. A model change that arrives without any input – no click, no key – still reaches the
// screen. This is the wake-up that a strict dirty flag on the widgets would have missed.
const beforeChange = (await stats()).drawn;
await evaluate(`__gm.state.setNotice('render check', 'info')`);
await sleep(150);
const afterChange = (await stats()).drawn;
check(
'a change nobody clicked for is drawn anyway',
afterChange > beforeChange,
`${beforeChange} → ${afterChange}`,
);
// 33c. Input wakes it, and it goes back to sleep on its own afterwards.
await settle();
const beforeClick = (await stats()).drawn;
const spot = await locate('Behavior');
await click(spot.x, spot.y);
const afterClick = (await stats()).drawn;
check('a click draws again', afterClick > beforeClick, `${beforeClick} → ${afterClick}`);
const settled = await settle();
const backToSleep = await drawnDuring(700);
check(
'and it stops on its own once the click is dealt with',
settled && backToSleep === 0,
`settled=${settled}, ${backToSleep} frames`,
);
// 33d. While the game runs, the frame cannot come from an event: the world moves by itself.
await play();
const playing = await drawnDuring(600);
const fps = await evaluate('__gm.fps()');
check(
'a running game keeps getting frames without being touched',
playing > 10,
`${playing} frames in 600 ms`,
);
check('and the fps readout keeps measuring them', typeof fps === 'number' && fps > 0, `fps=${fps}`);
await shot('01-playing');
await stop();
// 33e. Stopping ends the reason to draw. Back off the scene tab first: a Time shader left on
// screen by an earlier step is a legitimate reason to keep drawing, and would mask this check.
await openTab(t, 'graph');
const stopped = await settle();
const afterStop = await drawnDuring(700);
check(
'stopping puts it back to sleep',
stopped && afterStop === 0,
`settled=${stopped}, ${afterStop} frames`,
);
await evaluate(`__gm.state.setNotice('', 'info')`);
await sleep(200);
}
+2
View File
@@ -35,6 +35,7 @@ import { transform } from './29-transform.mjs';
import { font } from './30-font.mjs'; import { font } from './30-font.mjs';
import { particles } from './31-particles.mjs'; import { particles } from './31-particles.mjs';
import { scope } from './32-scope.mjs'; import { scope } from './32-scope.mjs';
import { renderOnChange } from './33-render-on-change.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -69,4 +70,5 @@ export const STEPS = [
font, font,
particles, particles,
scope, scope,
renderOnChange,
]; ];
+4
View File
@@ -141,6 +141,10 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
return p; return p;
}; };
const shot = async (name) => { const shot = async (name) => {
// The editor only draws on change, so a screenshot asks for one frame first: without it the
// shot shows whatever the compositor still holds, which is right but needlessly fragile.
await evaluate('window.__gm?.wake()').catch(() => {});
await sleep(50);
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' });
writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64')); writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64'));
}; };