Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions apps/desktop/src/main/browser-agent/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,8 @@ describe('browser-agent screenshot capture', () => {
expect(shot).toEqual({
dataUrl: `data:image/jpeg;base64,${Buffer.from('resized').toString('base64')}`,
scale: 0.5,
viewport: { width: 2048, height: 1024 },
imageSize: { width: 1024, height: 512 },
})
})

Expand All @@ -558,14 +560,115 @@ describe('browser-agent screenshot capture', () => {

const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
expect(image.resize).not.toHaveBeenCalled()
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
expect(shot).toEqual({
dataUrl: 'data:image/jpeg;base64,c2lt',
scale: 0.5,
viewport: { width: 2048, height: 1024 },
imageSize: { width: 1024, height: 512 },
})
})

it('returns the raw capture when the image cannot be decoded', async () => {
const { contents } = captureFixture(null)

const shot = await captureScreenshot(contents)

expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
expect(shot).toEqual({
dataUrl: 'data:image/jpeg;base64,c2lt',
scale: 0.5,
viewport: { width: 2048, height: 1024 },
imageSize: null,
})
})

it('does not expose deprecated device-pixel metrics as a CSS viewport', async () => {
const { contents } = captureFixture({ width: 1024, height: 512 })
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
if (method === 'Page.getLayoutMetrics') {
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
}
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
return Promise.resolve(undefined)
})

const shot = await captureScreenshot(contents)

expect(shot.viewport).toBeNull()
expect(shot.imageSize).toEqual({ width: 1024, height: 512 })
})

it('accepts stable finite scroll offsets around the capture', async () => {
const { contents } = captureFixture({ width: 1024, height: 512 })
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
if (method === 'Page.getLayoutMetrics') {
return Promise.resolve({
cssLayoutViewport: {
clientWidth: 2048,
clientHeight: 1024,
pageX: 12,
pageY: 34,
},
})
}
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
return Promise.resolve(undefined)
})

await expect(captureScreenshot(contents)).resolves.toMatchObject({
viewport: { width: 2048, height: 1024 },
imageSize: { width: 1024, height: 512 },
})
})

it.each([
[
'dimensions',
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } },
{ cssLayoutViewport: { clientWidth: 1024, clientHeight: 512 } },
],
[
'metric units',
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } },
{ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } },
],
[
'horizontal scroll offset',
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 0, pageY: 20 } },
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 10, pageY: 20 } },
],
[
'vertical scroll offset',
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 10, pageY: 20 } },
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 10, pageY: 30 } },
],
[
'offset validity',
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 0, pageY: 0 } },
{
cssLayoutViewport: {
clientWidth: 2048,
clientHeight: 1024,
pageX: 0,
pageY: Number.NaN,
},
},
],
['availability', {}, {}],
])(
'rejects a capture when viewport %s change during CDP capture',
async (_label, before, after) => {
const { contents } = captureFixture({ width: 1024, height: 512 })
let metricsRead = 0
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
if (method === 'Page.getLayoutMetrics') {
metricsRead++
return Promise.resolve(metricsRead === 1 ? before : after)
}
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
return Promise.resolve(undefined)
})

await expect(captureScreenshot(contents)).rejects.toThrow(/viewport changed/)
}
)
})
98 changes: 85 additions & 13 deletions apps/desktop/src/main/browser-agent/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,71 @@ const SCREENSHOT_CAPTURE_QUALITY = 90
interface CdpViewport {
clientWidth: number
clientHeight: number
pageX?: number
pageY?: number
}

interface ScreenshotViewportMetrics extends ScreenshotSize {
pageX: number | null
pageY: number | null
unit: 'css' | 'device'
}

interface ScreenshotSize {
width: number
height: number
}

export interface ScreenshotCapture {
dataUrl: string
scale: number
viewport: ScreenshotSize | null
imageSize: ScreenshotSize | null
}

function screenshotViewportMetrics(
metrics: {
cssLayoutViewport?: CdpViewport
layoutViewport?: CdpViewport
} | null
): ScreenshotViewportMetrics | null {
const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
const width = viewport?.clientWidth ?? 0
const height = viewport?.clientHeight ?? 0
if (width <= 0 || height <= 0) return null
const pageX = viewport?.pageX
const pageY = viewport?.pageY
const hasPagePosition = pageX !== undefined || pageY !== undefined
if (
hasPagePosition &&
(pageX === undefined ||
pageY === undefined ||
!Number.isFinite(pageX) ||
!Number.isFinite(pageY))
) {
return null
}
return {
width,
height,
pageX: pageX ?? null,
pageY: pageY ?? null,
unit: metrics?.cssLayoutViewport ? 'css' : 'device',
}
}

function sameScreenshotViewport(
before: ScreenshotViewportMetrics | null,
after: ScreenshotViewportMetrics | null
): boolean {
if (!before || !after) return false
return (
before.unit === after.unit &&
before.width === after.width &&
before.height === after.height &&
before.pageX === after.pageX &&
before.pageY === after.pageY
)
}

/**
Expand All @@ -401,44 +466,51 @@ interface CdpViewport {
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
* capture arrives at device resolution and this is what brings it back down.
*/
export async function captureScreenshot(
contents: WebContents
): Promise<{ dataUrl: string; scale: number }> {
export async function captureScreenshot(contents: WebContents): Promise<ScreenshotCapture> {
const metrics = await send<{
cssLayoutViewport?: CdpViewport
layoutViewport?: CdpViewport
}>(contents, 'Page.getLayoutMetrics').catch(() => null)

const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
const width = viewport?.clientWidth ?? 0
const height = viewport?.clientHeight ?? 0
const captureViewport = screenshotViewportMetrics(metrics)
const width = captureViewport?.width ?? 0
const height = captureViewport?.height ?? 0
const cssWidth = metrics?.cssLayoutViewport?.clientWidth ?? 0
const cssHeight = metrics?.cssLayoutViewport?.clientHeight ?? 0
const cssViewport = cssWidth > 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null
Comment thread
waleedlatif1 marked this conversation as resolved.
const scale =
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1

const result = await send<{ data: string }>(contents, 'Page.captureScreenshot', {
format: 'jpeg',
quality: SCREENSHOT_CAPTURE_QUALITY,
})
const metricsAfterCapture = await send<{
cssLayoutViewport?: CdpViewport
layoutViewport?: CdpViewport
}>(contents, 'Page.getLayoutMetrics').catch(() => null)
if (!sameScreenshotViewport(captureViewport, screenshotViewportMetrics(metricsAfterCapture))) {
throw new Error('The page viewport changed or could not be verified during screenshot capture')
}
const captured = `data:image/jpeg;base64,${result.data}`

const targetWidth = Math.round(width * scale)
const targetHeight = Math.round(height * scale)
// Without layout metrics there is no CSS frame of reference to resize
// against, so the raw capture is the honest answer — the same fallback the
// clipped path took.
if (targetWidth <= 0 || targetHeight <= 0) return { dataUrl: captured, scale }

const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
if (size.width === 0 || size.height === 0) return { dataUrl: captured, scale }
if (size.width === 0 || size.height === 0) {
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null }
}
if (size.width === targetWidth && size.height === targetHeight) {
return { dataUrl: captured, scale }
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size }
}

const resized = image.resize({ width: targetWidth, height: targetHeight, quality: 'good' })
return {
dataUrl: `data:image/jpeg;base64,${resized.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
scale,
viewport: cssViewport,
Comment thread
waleedlatif1 marked this conversation as resolved.
imageSize: { width: targetWidth, height: targetHeight },
}
}

Expand Down
Loading
Loading