|
| 1 | +import { constants } from "node:fs"; |
| 2 | +import { copyFile, lstat, open, stat } from "node:fs/promises"; |
| 3 | +import { dirname, isAbsolute, normalize } from "node:path"; |
| 4 | +/** Checks save_path, overwrite and region before anything is captured. */ |
| 5 | +export async function validateSave(request) { |
| 6 | + const { save_path: path, overwrite, region } = request; |
| 7 | + if (overwrite !== undefined && typeof overwrite !== "boolean") |
| 8 | + throw new Error("Choose overwrite true or false."); |
| 9 | + if (path === undefined) { |
| 10 | + if (overwrite !== undefined || region !== undefined) { |
| 11 | + throw new Error("overwrite and region apply to the saved file; pass save_path too."); |
| 12 | + } |
| 13 | + return undefined; |
| 14 | + } |
| 15 | + if (typeof path !== "string" || !isAbsolute(path) || normalize(path) !== path || path.length > 1024 |
| 16 | + || path.includes("\0") || !/\.png$/i.test(path)) { |
| 17 | + throw new Error("save_path must be a normalized absolute path ending in .png."); |
| 18 | + } |
| 19 | + if (region !== undefined) |
| 20 | + regionOf(region); |
| 21 | + const parent = await stat(dirname(path)).catch(() => undefined); |
| 22 | + if (!parent?.isDirectory()) |
| 23 | + throw new Error("save_path's folder must already exist."); |
| 24 | + const existing = await lstat(path).catch(() => undefined); |
| 25 | + if (existing) { |
| 26 | + if (!existing.isFile()) |
| 27 | + throw new Error("save_path names something other than a regular file; nothing was written."); |
| 28 | + if (overwrite !== true) { |
| 29 | + throw new Error("save_path already exists; pass overwrite true to replace it. Nothing was captured."); |
| 30 | + } |
| 31 | + } |
| 32 | + return path; |
| 33 | +} |
| 34 | +function regionOf(value) { |
| 35 | + const region = value; |
| 36 | + if (!region || typeof region !== "object" || Object.keys(region).length !== 4 |
| 37 | + || ![region.x, region.y, region.width, region.height].every((part) => typeof part === "number" && Number.isFinite(part)) |
| 38 | + || region.width <= 0 || region.height <= 0) { |
| 39 | + throw new Error("region must be x, y, width and height in global desktop points, with positive size."); |
| 40 | + } |
| 41 | + return region; |
| 42 | +} |
| 43 | +/** |
| 44 | + * Writes the full-resolution capture, or the part of it under region, as a PNG at path. area is the capture's |
| 45 | + * extent in global desktop points; the capture's own pixels map onto it, so nothing is scaled. |
| 46 | + */ |
| 47 | +export async function saveCapture(capture, scratch, path, request, area, tools, signal) { |
| 48 | + const size = await tools.dimensions(capture, signal); |
| 49 | + let source = capture; |
| 50 | + let region = null; |
| 51 | + if (request.region !== undefined) { |
| 52 | + region = regionOf(request.region); |
| 53 | + if (!area || area.width <= 0 || area.height <= 0) { |
| 54 | + throw new Error("This capture reported no desktop bounds, so a region can't be located in it."); |
| 55 | + } |
| 56 | + // Half a point of slack absorbs rounding in reported bounds; the crop still stays inside the image. |
| 57 | + const slack = 0.5; |
| 58 | + if (region.x < area.x - slack || region.y < area.y - slack |
| 59 | + || region.x + region.width > area.x + area.width + slack |
| 60 | + || region.y + region.height > area.y + area.height + slack) { |
| 61 | + throw new Error(`region must lie inside the captured area, x ${area.x}, y ${area.y}, width ${area.width}, height ${area.height} in desktop points.`); |
| 62 | + } |
| 63 | + const scaleX = size.width / area.width; |
| 64 | + const scaleY = size.height / area.height; |
| 65 | + const left = Math.max(0, Math.round((region.x - area.x) * scaleX)); |
| 66 | + const top = Math.max(0, Math.round((region.y - area.y) * scaleY)); |
| 67 | + const width = Math.min(size.width - left, Math.round(region.width * scaleX)); |
| 68 | + const height = Math.min(size.height - top, Math.round(region.height * scaleY)); |
| 69 | + if (width < 1 || height < 1) |
| 70 | + throw new Error("region covers less than one pixel of the capture."); |
| 71 | + source = `${scratch}/region.png`; |
| 72 | + await tools.run("/usr/bin/sips", [ |
| 73 | + "-s", "format", "png", |
| 74 | + "--cropOffset", String(top), String(left), |
| 75 | + "-c", String(height), String(width), |
| 76 | + capture, "--out", source, |
| 77 | + ], signal); |
| 78 | + } |
| 79 | + else if (!(await isPng(capture))) { |
| 80 | + source = `${scratch}/full.png`; |
| 81 | + await tools.run("/usr/bin/sips", ["-s", "format", "png", capture, "--out", source], signal); |
| 82 | + } |
| 83 | + const saved = await tools.dimensions(source, signal); |
| 84 | + signal.throwIfAborted(); |
| 85 | + // Exclusive unless overwrite was asked for, so a file created since validation is never replaced. |
| 86 | + await copyFile(source, path, request.overwrite === true ? 0 : constants.COPYFILE_EXCL); |
| 87 | + return { path, width: saved.width, height: saved.height, region }; |
| 88 | +} |
| 89 | +async function isPng(path) { |
| 90 | + const file = await open(path, "r"); |
| 91 | + try { |
| 92 | + const head = Buffer.alloc(8); |
| 93 | + await file.read(head, 0, 8, 0); |
| 94 | + return head.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); |
| 95 | + } |
| 96 | + finally { |
| 97 | + await file.close(); |
| 98 | + } |
| 99 | +} |
0 commit comments