import { readFile } from "node:fs/promises"; import { extname } from "node:path"; import { pathToFileURL } from "node:url"; import type { Attachment, PendingAttachment } from "./types"; /** * Reading attachments off disk, and describing them to the renderer. * * Kept separate from `capture.ts` because the two answer different questions: * capture is about getting an image *onto* the disk, this is about getting it * back off again and telling Vicinae how to draw it. * * Imports no Vicinae module, so it unit tests with no runtime. */ /** Extensions Ollama accepts. A snip is always PNG; the rest are here for a future picker. */ const MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", }; /** * The box a preview is fitted into. * * The height is not a preference: `MdImage.qml` clamps rendered height to 200px * whatever is asked for (`Math.min(_rawH, 200, maxImageHeight)`), so a taller * request is silently ignored. The width is here to keep a very wide capture * from being scaled up past the detail pane it sits in. */ const PREVIEW_MAX_WIDTH = 400; const PREVIEW_MAX_HEIGHT = 200; export function mimeForPath(path: string): string { return MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream"; } /** The eight bytes every PNG starts with. */ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; /** * Pixel dimensions from a PNG's `IHDR` chunk, or `undefined` for anything else. * * Only PNG is handled, because that is the only thing a snip produces and * guessing at other formats would be a lie. The header is 24 bytes, so this * costs nothing on top of the read that is already happening. */ export function pngSize(bytes: Buffer): { width: number; height: number } | undefined { if (bytes.length < 24) return undefined; if (!PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) return undefined; if (bytes.toString("ascii", 12, 16) !== "IHDR") return undefined; const width = bytes.readUInt32BE(16); const height = bytes.readUInt32BE(20); return width > 0 && height > 0 ? { width, height } : undefined; } /** * The exact box to ask the renderer for, or `undefined` when the size is unknown. * * This is the whole point of reading the header. With no hint, `MdImage.qml` * sizes its container to the pane width and 200px tall and then aspect-fits the * image inside it, so a wide screenshot sits in a letterbox with dead space * above and below it. Asking for a box that already matches the image's aspect * ratio leaves none. * * The scale is capped at 1 so a small capture is not blown up into a blurry one. */ export function previewBox( width: number | undefined, height: number | undefined, ): { width: number; height: number } | undefined { if (!width || !height || width <= 0 || height <= 0) return undefined; const scale = Math.min(1, PREVIEW_MAX_WIDTH / width, PREVIEW_MAX_HEIGHT / height); return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), }; } /** * A markdown image source for a local file. * * A `file://` URL rather than a bare path, because Vicinae's markdown renderer * only resolves a scheme it recognises: `imageProviderUrl` in * `markdown-model.cpp` routes `file://`, and a scheme-less string, through * `ImageURL::local`, and anything it does not recognise it returns as empty. * `pathToFileURL` does the escaping, so a capture under a directory with a space * in its name still resolves. * * The query is read from the URL before it is resolved to a path, so the size * hints travel here without ever reaching the file name. They are spelled the * way Raycast spelled them, which is what the parser looks for. */ export function imageSource(attachment: Pick): string { const url = pathToFileURL(attachment.path); const box = previewBox(attachment.width, attachment.height); if (box) { url.searchParams.set("raycast-width", String(box.width)); url.searchParams.set("raycast-height", String(box.height)); } return url.href; } /** * Reads an image into the shape a request needs. * * Ollama wants base64 with no `data:` URL prefix, which is why the payload is * built here rather than handed straight to the renderer. The dimensions are * kept for the same reason the path is: the transcript needs them long after * this call is over, and the preview layout depends on them. */ export async function readAttachment(path: string): Promise { const bytes = await readFile(path); const size = pngSize(bytes); return { path, mime: mimeForPath(path), base64: bytes.toString("base64"), width: size?.width, height: size?.height, }; } /** The persistable half of a pending attachment: everything except the base64 payload. */ export function toStoredAttachment(attachment: PendingAttachment): Attachment { const { path, mime, width, height } = attachment; return { path, mime, width, height }; }