Files
hermes-agent/apps/desktop/electron/preview-capture.ts
T
Adolanium 10f2a20966 feat(desktop): add comment mode to the in-app browser
Click or drag on the preview page to pin a numbered comment. Saving a pin only adds it to the stack. Add comments drops the crops and a short prompt into the composer and never sends the turn.

Capture takes the visible page then crops in bitmap space, because Electron's rect crop on guest webviews is empty on Windows and shifted on high-DPI.
2026-09-01 00:30:56 -07:00

132 lines
3.4 KiB
TypeScript

/**
* Crop a preview webview via webContents.capturePage.
*
* Electron's rect argument is unreliable on guest webviews (empty NativeImage
* on Windows, DPI-shifted crops). Capture the visible viewport, then crop in
* bitmap space from CSS viewport coordinates.
*
* Missing/destroyed guests fail closed — never a blank PNG that would look
* like a successful shot of nothing.
*/
export interface CaptureRect {
height: number
width: number
x: number
y: number
}
export interface CaptureViewport {
height: number
width: number
}
export interface CaptureGuest {
capturePage: (rect?: CaptureRect) => Promise<CaptureImage>
isDestroyed: () => boolean
}
export interface CaptureImage {
crop?: (rect: CaptureRect) => CaptureImage
getSize?: () => CaptureViewport
isEmpty: () => boolean
toPNG: () => Buffer
}
export function normalizeCaptureRect(rect?: CaptureRect): CaptureRect | undefined {
if (!rect) {
return undefined
}
return {
height: Math.max(1, Math.ceil(rect.height)),
width: Math.max(1, Math.ceil(rect.width)),
x: Math.max(0, Math.floor(rect.x)),
y: Math.max(0, Math.floor(rect.y))
}
}
/** Map a CSS-pixel viewport rect onto a (possibly DPR-scaled) bitmap. */
export function mapViewportRectToImage(
rect: CaptureRect,
viewport: CaptureViewport,
image: CaptureViewport
): CaptureRect | null {
const viewW = Math.max(1, viewport.width)
const viewH = Math.max(1, viewport.height)
const scaleX = image.width / viewW
const scaleY = image.height / viewH
const left = rect.x * scaleX
const top = rect.y * scaleY
const right = (rect.x + rect.width) * scaleX
const bottom = (rect.y + rect.height) * scaleY
const x = Math.max(0, Math.floor(left))
const y = Math.max(0, Math.floor(top))
const maxX = Math.min(image.width, Math.ceil(right))
const maxY = Math.min(image.height, Math.ceil(bottom))
const width = maxX - x
const height = maxY - y
if (width < 1 || height < 1) {
return null
}
return { height, width, x, y }
}
function toDataUrl(image: CaptureImage): string {
if (!image || image.isEmpty()) {
throw new Error('preview capture was empty')
}
return `data:image/png;base64,${image.toPNG().toString('base64')}`
}
export async function capturePreviewContents(
guest: CaptureGuest | null | undefined,
rect?: CaptureRect,
viewport?: CaptureViewport
): Promise<string> {
if (!guest || guest.isDestroyed()) {
throw new Error('preview guest is gone')
}
const image = await guest.capturePage()
if (!image || image.isEmpty()) {
throw new Error('preview capture was empty')
}
const crop = normalizeCaptureRect(rect)
if (!crop) {
return toDataUrl(image)
}
const size = image.getSize?.()
const view = viewport && viewport.width > 0 && viewport.height > 0 ? viewport : size
if (size && view && typeof image.crop === 'function') {
const mapped = mapViewportRectToImage(crop, view, size)
if (mapped) {
const cropped = image.crop(mapped)
if (cropped && !cropped.isEmpty()) {
return toDataUrl(cropped)
}
}
// Off-screen CSS rects (document Y after scroll, DPI mismatch) used to
// throw here and the composer kept the stale pick-time crop. The visible
// page is a better fallback than the wrong slice of the article.
return toDataUrl(image)
}
try {
return toDataUrl(await guest.capturePage(crop))
} catch {
return toDataUrl(image)
}
}