返回 DeepSeek-Reasonix
screenshot.ts
根目录 / desktop / electron / src / main / browser / screenshot.ts
1 import { existsSync, statSync } from "node:fs";
2 import { writeFile, rename, rm } from "node:fs/promises";
3 import { isAbsolute, join } from "node:path";
4 import type { DocumentBinding } from "./documents.js";
5 import type { GuestPage } from "./guestView.js";
6 import { resolveRef } from "./refResolver.js";
7 import { browserFailure, staleReference } from "./errors.js";
8 import { MAX_CAPTURE_PIXELS, validatePNG } from "./png.js";
9 import { acquireDebugger } from "./debuggerLease.js";
10 export { pngSize } from "./png.js";
11
12 export interface ScreenshotRequest {
13 ref: string;
14 fullPage: boolean;
15 directory: string;
16 }
17
18 export interface ScreenshotResult {
19 path: string;
20 mime: "image/png";
21 width: number;
22 height: number;
23 reason?: string;
24 observationToken?: string;
25 cssWidth?: number;
26 cssHeight?: number;
27 }
28
29 export interface ScreenshotDeps {
30 directoryExists?(path: string): boolean;
31 writeFile?(path: string, data: Buffer): void | Promise<void>;
32 decodePNG?(data: Buffer): { width: number; height: number };
33 verify?(): void;
34 viewport?: { width: number; height: number } | null;
35 pixelRatio?: number;
36 now?(): number;
37 }
38
39 let sequence = 0;
40
41 const defaultDirectoryExists = (path: string) => {
42 try {
43 return existsSync(path) && statSync(path).isDirectory();
44 } catch {
45 return false;
46 }
47 };
48
49 export function screenshotPath(directory: string, now: number): string {
50 sequence += 1;
51 return join(directory, `shot-${now}-${sequence}.png`);
52 }
53
54 // Full-page captures go through the DevTools protocol because a
55 // WebContentsView cannot be resized past the window; everything else uses
56 // capturePage on the visible viewport or the element's rect.
57 export async function captureScreenshot(page: GuestPage, binding: DocumentBinding | null, zoom: number, request: ScreenshotRequest, deps: ScreenshotDeps = {}): Promise<ScreenshotResult> {
58 const directoryExists = deps.directoryExists ?? defaultDirectoryExists;
59 if (!isAbsolute(request.directory) || !directoryExists(request.directory)) throw new Error("screenshot directory must be an existing absolute path");
60 const target = screenshotPath(request.directory, (deps.now ?? Date.now)());
61 deps.verify?.();
62 let png: Buffer;
63 let size: { width: number; height: number };
64 if (request.fullPage) {
65 const full = await captureFullPage(page, deps.pixelRatio ?? 1);
66 png = full.png;
67 size = full.size;
68 } else {
69 let rect: Electron.Rectangle | undefined;
70 if (request.ref !== "") {
71 if (!binding) throw new Error("element screenshots need a snapshot first");
72 const resolved = await resolveRef(page, binding, request.ref, true);
73 if (resolved.ok) {
74 const { element } = resolved.value;
75 rect = {
76 x: Math.max(0, Math.floor(element.x * zoom)),
77 y: Math.max(0, Math.floor(element.y * zoom)),
78 width: Math.max(1, Math.ceil(element.width * zoom)),
79 height: Math.max(1, Math.ceil(element.height * zoom)),
80 };
81 } else throw staleReference(`cannot capture the requested element: ${resolved.reason}`);
82 }
83 if (deps.viewport) {
84 const clip = rect ? { x: rect.x / zoom, y: rect.y / zoom, width: rect.width / zoom, height: rect.height / zoom } : { x: 0, y: 0, ...deps.viewport };
85 const dbg = page.debugger;
86 const release = acquireDebugger(dbg);
87 try {
88 const metrics = await release.send("Page.getLayoutMetrics") as { cssVisualViewport?: { pageX: number; pageY: number } };
89 const result = await release.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: true, clip: { ...clip, x: clip.x + (metrics.cssVisualViewport?.pageX ?? 0), y: clip.y + (metrics.cssVisualViewport?.pageY ?? 0), scale: 1 / (deps.pixelRatio ?? 1) } }) as { data?: string };
90 png = Buffer.from(result.data ?? "", "base64");
91 size = { width: Math.round(clip.width), height: Math.round(clip.height) };
92 } finally { release(); }
93 } else {
94 const image = await page.capturePage(rect);
95 png = image.toPNG();
96 size = image.getSize();
97 }
98 }
99 const actual = validatePNG(png);
100 const decoded = deps.decodePNG?.(png) ?? actual;
101 if (actual.width !== size.width || actual.height !== size.height || decoded.width !== actual.width || decoded.height !== actual.height) {
102 throw browserFailure("invalid_image", `PNG dimensions ${actual.width}x${actual.height} do not match captured surface ${size.width}x${size.height} (decoded ${decoded.width}x${decoded.height})`);
103 }
104 deps.verify?.();
105 if (deps.writeFile) await deps.writeFile(target, png);
106 else {
107 const temporary = `${target}.tmp`;
108 try {
109 await writeFile(temporary, png, { flag: "wx", mode: 0o600 });
110 deps.verify?.();
111 await rename(temporary, target);
112 deps.verify?.();
113 } catch (error) {
114 await rm(temporary, { force: true });
115 await rm(target, { force: true });
116 throw error;
117 }
118 }
119 return { path: target, mime: "image/png", width: actual.width, height: actual.height };
120 }
121
122 async function captureFullPage(page: GuestPage, pixelRatio: number): Promise<{ png: Buffer; size: { width: number; height: number } }> {
123 const dbg = page.debugger;
124 const release = acquireDebugger(dbg);
125 try {
126 const metrics = await release.send("Page.getLayoutMetrics") as { cssContentSize?: { width: number; height: number } };
127 const content = metrics.cssContentSize;
128 if (!content || !Number.isFinite(content.width * content.height) || content.width <= 0 || content.height <= 0 || content.width * content.height > MAX_CAPTURE_PIXELS) {
129 throw browserFailure("invalid_image", "full page exceeds the capture pixel budget; capture the viewport or an element");
130 }
131 const size = { width: Math.ceil(content.width), height: Math.ceil(content.height) };
132 if (size.width * size.height > MAX_CAPTURE_PIXELS) throw browserFailure("invalid_image", "full page exceeds the capture pixel budget");
133 const result = (await release.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, fromSurface: true, clip: { x: 0, y: 0, ...size, scale: 1 / pixelRatio } })) as { data?: unknown };
134 if (typeof result?.data !== "string") throw new Error("Page.captureScreenshot returned no image");
135 return { png: Buffer.from(result.data, "base64"), size };
136 } finally {
137 release();
138 }
139 }
140
140 lines TYPESCRIPT