diff --git a/apps/desktop-client/src/main/adapters/media-image.test.ts b/apps/desktop-client/src/main/adapters/media-image.test.ts new file mode 100644 index 0000000..23376e2 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/media-image.test.ts @@ -0,0 +1,44 @@ +import { Buffer } from "node:buffer"; + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + nativeImage: { + createFromBuffer: () => ({ + isEmpty: () => false, + getSize: () => ({ width: 1, height: 1 }), + crop: () => ({ + isEmpty: () => false, + getSize: () => ({ width: 1, height: 1 }), + toPNG: () => Buffer.from([]), + }), + toPNG: () => Buffer.from([]), + }), + }, +})); + +vi.mock("../transport/api-client", () => ({ + resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`, +})); + +import { + buildAssetURLCandidates, + getCenteredCropRect, +} from "./media-image"; + +describe("media image helpers", () => { + it("resolves API asset URLs through the desktop API base and requests png output", () => { + expect(buildAssetURLCandidates("/api/public/assets/12").at(0)).toBe( + "http://127.0.0.1:8080/api/public/assets/12?format=png", + ); + }); + + it("centers crop rectangles at the requested ratio", () => { + const rect = getCenteredCropRect(1600, 1000, 4 / 3); + + expect(rect.x).toBeCloseTo(133.333, 3); + expect(rect.y).toBeCloseTo(0); + expect(rect.width).toBeCloseTo(1333.333, 3); + expect(rect.height).toBeCloseTo(1000); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/media-image.ts b/apps/desktop-client/src/main/adapters/media-image.ts new file mode 100644 index 0000000..50fdbec --- /dev/null +++ b/apps/desktop-client/src/main/adapters/media-image.ts @@ -0,0 +1,138 @@ +import { Buffer } from "node:buffer"; + +import { nativeImage } from "electron"; + +import { 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"]); + +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()); + } + + pushCandidate(parsed.toString()); + } catch { + pushCandidate(trimmed); + } + + return candidates; +} + +export async function fetchImageAssetBlob(sourceUrl: string): Promise { + for (const candidate of buildAssetURLCandidates(sourceUrl)) { + const response = await 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: sourceType === "image/gif" ? "image.gif" : sourceType === "image/jpeg" || sourceType === "image/jpg" ? "image.jpg" : "image.png", + }; + } + + 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", + }; +}