import { Buffer } from "node:buffer"; import { nativeImage } from "electron"; import { fetchDesktopApiURL, resolveDesktopApiURL } from "../transport/api-client"; export interface ImageAssetBlob { blob: Blob; width: number; height: number; fileName: string; } export interface CropRect { x: number; y: number; width: number; height: number; } const passthroughImageTypes = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif"]); function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null { if (!publicAssetUrl.pathname.startsWith("/api/public/assets/")) { return null; } const token = publicAssetUrl.pathname.slice("/api/public/assets/".length); if (!token) { return null; } const fallbackURL = new URL(resolveDesktopApiURL(`/api/desktop/content/assets/${token}`)); publicAssetUrl.searchParams.forEach((value, key) => { fallbackURL.searchParams.set(key, value); }); fallbackURL.searchParams.set("format", "png"); return fallbackURL.toString(); } export function buildAssetURLCandidates(sourceUrl: string): string[] { const trimmed = sourceUrl.trim(); if (!trimmed) { return []; } if (/^(data|blob):/i.test(trimmed)) { return [trimmed]; } const candidates: string[] = []; const pushCandidate = (value: string) => { if (!candidates.includes(value)) { candidates.push(value); } }; try { const parsed = new URL(trimmed, resolveDesktopApiURL("/")); if (parsed.pathname.startsWith("/api/")) { const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)); if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) { apiAssetUrl.searchParams.set("format", "png"); } pushCandidate(apiAssetUrl.toString()); const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl); if (desktopAssetFallbackURL) { pushCandidate(desktopAssetFallbackURL); } } pushCandidate(parsed.toString()); } catch { pushCandidate(trimmed); } return candidates; } function shouldUseDesktopAssetFetch(candidate: string): boolean { try { return new URL(candidate).pathname.startsWith("/api/desktop/content/assets/"); } catch { return false; } } function fileNameForImageType(sourceType: string): string { if (sourceType === "image/gif") { return "image.gif"; } if (sourceType === "image/jpeg" || sourceType === "image/jpg") { return "image.jpg"; } return "image.png"; } export async function fetchImageAssetBlob(sourceUrl: string): Promise { for (const candidate of buildAssetURLCandidates(sourceUrl)) { const response = await ( shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate) : fetch(candidate) ).catch(() => null); if (!response?.ok) { continue; } const sourceBlob = await response.blob().catch(() => null); if (!sourceBlob || !sourceBlob.type.startsWith("image/")) { continue; } const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer())); if (image.isEmpty()) { continue; } const size = image.getSize(); const sourceType = sourceBlob.type.toLowerCase(); if (passthroughImageTypes.has(sourceType)) { return { blob: sourceBlob, width: size.width, height: size.height, fileName: fileNameForImageType(sourceType), }; } return { blob: new Blob([new Uint8Array(image.toPNG())], { type: "image/png" }), width: size.width, height: size.height, fileName: "image.png", }; } return null; } export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect { let cropWidth = width; let cropHeight = cropWidth / ratio; if (cropHeight > height) { cropHeight = height; cropWidth = cropHeight * ratio; } return { x: (width - cropWidth) / 2, y: (height - cropHeight) / 2, width: cropWidth, height: cropHeight, }; } export async function cropImageAssetBlob(source: ImageAssetBlob, ratio: number): Promise { const image = nativeImage.createFromBuffer(Buffer.from(await source.blob.arrayBuffer())); if (image.isEmpty()) { return null; } const crop = getCenteredCropRect(source.width, source.height, ratio); const cropped = image.crop({ x: Math.max(0, Math.round(crop.x)), y: Math.max(0, Math.round(crop.y)), width: Math.max(1, Math.round(crop.width)), height: Math.max(1, Math.round(crop.height)), }); if (cropped.isEmpty()) { return null; } const size = cropped.getSize(); return { blob: new Blob([new Uint8Array(cropped.toPNG())], { type: "image/png" }), width: size.width, height: size.height, fileName: "image.png", }; }