diff --git a/src/index.ts b/src/index.ts index cb066b9..e9e0b8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export { applyBrandToPresetOptions, brandFonts, brandKitJsonSchema, brandTemplat export { createContactSheet } from "./composition/contact-sheet.js"; export { ClickClickError } from "./errors.js"; export { barChart, collage, contactSheet, imageGrid, qrCode } from "./composition/index.js"; -export { serializeMediaSource } from "./media/index.js"; +export { resolveAssetSource, serializeMediaSource } from "./media/index.js"; export { dataRowToLayerModifications, generateTemplateBatch, interpolateOutputPattern } from "./generate/index.js"; export { createRenderer, renderImage, screenshotUrl } from "./renderer/index.js"; export { listConfigTemplates, loadConfig, renderRecipe, renderTemplate, renderTemplateSet } from "./template/index.js"; @@ -79,6 +79,11 @@ export type { RenderOutputOptions, RenderQualityInput, RenderWarning, + AssetDiagnostic, + AssetDiagnosticCode, + AssetPipelineOptions, + AssetTransformOptions, + ResolvedAsset, ScreenshotUrlInput, ScreenshotUrlLifecycleOptions, ScreenshotUrlOptions, diff --git a/src/media/index.ts b/src/media/index.ts index 1a73e58..5e4dad4 100644 --- a/src/media/index.ts +++ b/src/media/index.ts @@ -1,6 +1,22 @@ +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { extname, resolve } from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { extname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { ClickClickError } from "../errors.js"; +import type { AssetDiagnostic, AssetPipelineOptions, AssetTransformOptions, ResolvedAsset } from "../types.js"; + +const DEFAULT_ASSET_CACHE_DIR = ".clickclick-cache/assets"; +const DEFAULT_MAX_ASSET_BYTES = 10 * 1024 * 1024; +const SUPPORTED_IMAGE_MIME_TYPES = new Set([ + "image/apng", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/webp", +]); export function serializeMediaSource(src: string, baseDir?: string): string { if (/^file:/i.test(src)) return serializeMediaFilePath(fileURLToPath(src)); @@ -9,6 +25,75 @@ export function serializeMediaSource(src: string, baseDir?: string): string { return serializeMediaFilePath(path); } +export async function resolveAssetSource(src: string, options: AssetPipelineOptions = {}): Promise { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_ASSET_BYTES; + const diagnostics: AssetDiagnostic[] = []; + + if (src.startsWith("#")) { + return { + source: src, + url: src, + mimeType: "text/plain", + diagnostics, + }; + } + + if (/^data:/i.test(src)) { + const parsed = parseDataUrl(src); + if (!parsed) { + diagnostics.push({ + code: "ASSET_INVALID_DATA_URL", + severity: "warning", + message: `Asset data URL could not be parsed: ${src.slice(0, 80)}`, + source: src, + }); + return { source: src, url: src, mimeType: "application/octet-stream", diagnostics }; + } + diagnostics.push(...diagnosticsForAsset(src, parsed.mimeType, parsed.bytes, maxBytes)); + return { + source: src, + url: await serializeResolvedAsset(src, parsed.mimeType, parsed.bytes, options.transform, diagnostics), + mimeType: normalizedMimeType(parsed.mimeType), + bytes: parsed.bytes, + diagnostics, + }; + } + + if (/^https?:/i.test(src)) { + return resolveRemoteAsset(src, options, maxBytes); + } + + const path = /^file:/i.test(src) + ? fileURLToPath(src) + : resolveExistingMediaPath(src, options.baseDir); + try { + const bytes = await readFile(path); + const mimeType = mimeTypeForPath(path); + diagnostics.push(...diagnosticsForAsset(src, mimeType, bytes, maxBytes)); + return { + source: src, + url: await serializeResolvedAsset(src, mimeType, bytes, options.transform, diagnostics), + mimeType, + bytes, + diagnostics, + }; + } catch { + diagnostics.push({ + code: "ASSET_MISSING", + severity: "warning", + message: `Asset could not be read: ${path}`, + source: src, + details: { path }, + }); + return { + source: src, + url: pathToFileURL(path).href, + mimeType: mimeTypeForPath(path), + diagnostics, + }; + } +} + function serializeMediaFilePath(path: string): string { if (!existsSync(path)) return pathToFileURL(path).href; const mimeType = mimeTypeForPath(path); @@ -46,3 +131,185 @@ function mimeTypeForPath(path: string): string { return "application/octet-stream"; } } + +async function resolveRemoteAsset(src: string, options: AssetPipelineOptions, maxBytes: number): Promise { + const diagnostics: AssetDiagnostic[] = []; + const key = createHash("sha256") + .update(stableAssetKey({ src, transform: options.transform })) + .digest("hex"); + const cacheDir = resolve(options.cacheDir ?? DEFAULT_ASSET_CACHE_DIR); + const cachePath = join(cacheDir, `${key}.asset`); + const metadataPath = join(cacheDir, `${key}.json`); + + try { + const [metadataRaw, bytes] = await Promise.all([ + readFile(metadataPath, "utf8"), + readFile(cachePath), + ]); + const metadata = JSON.parse(metadataRaw) as { source?: string; mimeType?: string }; + if (metadata.source === src && typeof metadata.mimeType === "string") { + diagnostics.push(...diagnosticsForAsset(src, metadata.mimeType, bytes, maxBytes)); + return { + source: src, + url: await serializeResolvedAsset(src, metadata.mimeType, bytes, options.transform, diagnostics), + mimeType: metadata.mimeType, + bytes, + cache: { hit: true, key, path: cachePath }, + diagnostics, + }; + } + } catch { + // Cache misses are expected. + } + + try { + const response = await fetch(src); + if (!response.ok) { + throw new ClickClickError("INVALID_INPUT", `HTTP ${response.status} ${response.statusText}`); + } + const mimeType = normalizedMimeType(response.headers.get("content-type")?.split(";")[0] ?? mimeTypeForPath(new URL(src).pathname)); + const bytes = Buffer.from(await response.arrayBuffer()); + diagnostics.push(...diagnosticsForAsset(src, mimeType, bytes, maxBytes)); + await mkdir(cacheDir, { recursive: true }); + await Promise.all([ + writeFile(cachePath, bytes), + writeFile(metadataPath, JSON.stringify({ source: src, mimeType }, null, 2)), + ]); + return { + source: src, + url: await serializeResolvedAsset(src, mimeType, bytes, options.transform, diagnostics), + mimeType, + bytes, + cache: { hit: false, key, path: cachePath }, + diagnostics, + }; + } catch (error) { + diagnostics.push({ + code: "ASSET_REMOTE_ERROR", + severity: "warning", + message: `Remote asset could not be fetched: ${src}`, + source: src, + details: { error: error instanceof Error ? error.message : String(error) }, + }); + return { + source: src, + url: src, + mimeType: mimeTypeForPath(new URL(src).pathname), + cache: { hit: false, key, path: cachePath }, + diagnostics, + }; + } +} + +async function serializeResolvedAsset(src: string, mimeType: string, bytes: Buffer, transform: AssetTransformOptions | undefined, diagnostics: AssetDiagnostic[]): Promise { + const transformed = applySvgTransform(src, normalizedMimeType(mimeType), bytes, transform, diagnostics); + return `data:${transformed.mimeType};base64,${transformed.bytes.toString("base64")}`; +} + +function applySvgTransform(src: string, mimeType: string, bytes: Buffer, transform: AssetTransformOptions | undefined, diagnostics: AssetDiagnostic[]): { mimeType: string; bytes: Buffer } { + if (!transform) return { mimeType, bytes }; + const requestedWidth = transform.width ?? transform.resize?.width; + const requestedHeight = transform.height ?? transform.resize?.height; + const requestedFormat = transform.format; + const unsupportedBitmapTransform = transform.crop || transform.fit || transform.focalPoint || requestedFormat && requestedFormat !== "svg" && requestedFormat !== mimeTypeToFormat(mimeType); + + if (mimeType !== "image/svg+xml") { + if (requestedWidth || requestedHeight || unsupportedBitmapTransform) { + diagnostics.push({ + code: "ASSET_TRANSFORM_UNSUPPORTED", + severity: "warning", + message: "Bitmap asset transforms require an external image pipeline and were left unchanged.", + source: src, + }); + } + return { mimeType, bytes }; + } + + if (unsupportedBitmapTransform || requestedFormat && requestedFormat !== "svg") { + diagnostics.push({ + code: "ASSET_TRANSFORM_UNSUPPORTED", + severity: "warning", + message: "SVG normalization supports width and height only; crop, focal point, fit, and raster format conversion were left unchanged.", + source: src, + }); + } + + let svg = bytes.toString("utf8").trim(); + if (requestedWidth) { + svg = setSvgAttribute(svg, "width", String(requestedWidth)); + } + if (requestedHeight) { + svg = setSvgAttribute(svg, "height", String(requestedHeight)); + } + return { mimeType, bytes: Buffer.from(svg) }; +} + +function diagnosticsForAsset(src: string, mimeType: string, bytes: Buffer, maxBytes: number): AssetDiagnostic[] { + const diagnostics: AssetDiagnostic[] = []; + const normalized = normalizedMimeType(mimeType); + if (!SUPPORTED_IMAGE_MIME_TYPES.has(normalized)) { + diagnostics.push({ + code: "ASSET_UNSUPPORTED_FORMAT", + severity: "warning", + message: `Asset format is not a supported image type: ${normalized}`, + source: src, + }); + } + if (bytes.byteLength > maxBytes) { + diagnostics.push({ + code: "ASSET_TOO_LARGE", + severity: "warning", + message: `Asset is larger than ${maxBytes} bytes: ${bytes.byteLength}`, + source: src, + details: { bytes: bytes.byteLength, maxBytes }, + }); + } + return diagnostics; +} + +function parseDataUrl(src: string): { mimeType: string; bytes: Buffer } | undefined { + const match = /^data:([^;,]+)?((?:;[^,]+)*),(.*)$/is.exec(src); + if (!match) return undefined; + const mimeType = normalizedMimeType(match[1] || "text/plain"); + const metadata = match[2] ?? ""; + const body = match[3] ?? ""; + const bytes = metadata.includes(";base64") + ? Buffer.from(body, "base64") + : Buffer.from(decodeURIComponent(body), "utf8"); + return { mimeType, bytes }; +} + +function normalizedMimeType(value: string): string { + const lower = value.toLowerCase(); + return lower === "image/jpg" ? "image/jpeg" : lower; +} + +function stableAssetKey(value: unknown): string { + return JSON.stringify(sortValue(value)); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, item]) => item !== undefined && typeof item !== "function") + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sortValue(item)]), + ); +} + +function setSvgAttribute(svg: string, attribute: "width" | "height", value: string): string { + if (!/^]/i.test(svg)) return svg; + const escaped = value.replaceAll('"', """); + const attributePattern = new RegExp(`\\s${attribute}=(["']).*?\\1`, "i"); + if (attributePattern.test(svg)) return svg.replace(attributePattern, ` ${attribute}="${escaped}"`); + return svg.replace(/^ { return warnings; }`; -function serializeLayerModificationSources(modifications: LayerModification[], baseDir: string | undefined): LayerModification[] { - return modifications.map((modification) => { +async function serializeLayerModificationSources(modifications: LayerModification[], baseDir: string | undefined): Promise<{ modifications: LayerModification[]; warnings: TemplateWarning[] }> { + const warnings: TemplateWarning[] = []; + const resolved = await Promise.all(modifications.map(async (modification) => { const src = modification.src ?? modification.image_url; if (!src) return modification; - const serialized = serializeMediaSource(src, baseDir); + const asset = await resolveAssetSource(src, { baseDir }); + warnings.push(...asset.diagnostics.map((diagnostic) => ({ + code: "ASSET_DIAGNOSTIC" as const, + layer: modification.name, + message: `${diagnostic.code}: ${diagnostic.message}`, + }))); return modification.src !== undefined - ? { ...modification, src: serialized } - : { ...modification, image_url: serialized }; - }); + ? { ...modification, src: asset.url } + : { ...modification, image_url: asset.url }; + })); + return { modifications: resolved, warnings }; } function fontFaceCss(fonts: TemplateInput["fonts"] = []): string { diff --git a/src/types.ts b/src/types.ts index 19182cb..1681c35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,7 +81,7 @@ export interface TextFitWarning { } export interface TemplateWarning { - code: "MISSING_LAYER" | "DUPLICATE_LAYER"; + code: "MISSING_LAYER" | "DUPLICATE_LAYER" | "ASSET_DIAGNOSTIC"; message: string; layer: string; } @@ -117,6 +117,52 @@ export interface RenderCacheInfo { skippedReason?: "disabled" | "beforeScreenshot"; } +export type AssetDiagnosticCode = + | "ASSET_MISSING" + | "ASSET_TOO_LARGE" + | "ASSET_UNSUPPORTED_FORMAT" + | "ASSET_REMOTE_ERROR" + | "ASSET_INVALID_DATA_URL" + | "ASSET_TRANSFORM_UNSUPPORTED"; + +export interface AssetDiagnostic { + code: AssetDiagnosticCode; + severity: "warning" | "error"; + message: string; + source: string; + details?: Record; +} + +export interface AssetTransformOptions { + width?: number; + height?: number; + resize?: { width?: number; height?: number }; + crop?: { x: number; y: number; width: number; height: number }; + fit?: "cover" | "contain" | "fill" | "none" | "scale-down"; + focalPoint?: { x: number; y: number }; + format?: ImageFormat | "svg"; +} + +export interface AssetPipelineOptions { + baseDir?: string; + cacheDir?: string; + maxBytes?: number; + transform?: AssetTransformOptions; +} + +export interface ResolvedAsset { + source: string; + url: string; + mimeType: string; + bytes?: Buffer; + cache?: { + hit: boolean; + key?: string; + path?: string; + }; + diagnostics: AssetDiagnostic[]; +} + export interface FontRegistryEntry { family: string; source: string | string[]; diff --git a/test/media.test.ts b/test/media.test.ts index 18aa8e4..7cac4ed 100644 --- a/test/media.test.ts +++ b/test/media.test.ts @@ -1,9 +1,10 @@ +import { createServer, type Server } from "node:http"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { serializeMediaSource } from "../src/index.js"; +import { resolveAssetSource, serializeMediaSource } from "../src/index.js"; let tempDir: string; @@ -40,3 +41,66 @@ describe("media source serialization", () => { expect(serializeMediaSource("missing.png", tempDir)).toBe(pathToFileURL(join(process.cwd(), "missing.png")).href); }); }); + +describe("asset pipeline", () => { + it("normalizes local files and file URLs to data URLs with diagnostics", async () => { + const assetPath = join(tempDir, "pipeline-logo.svg"); + await writeFile(assetPath, ''); + + const local = await resolveAssetSource("pipeline-logo.svg", { baseDir: tempDir }); + const file = await resolveAssetSource(pathToFileURL(assetPath).href); + + expect(local).toMatchObject({ source: "pipeline-logo.svg", mimeType: "image/svg+xml", diagnostics: [] }); + expect(local.url).toMatch(/^data:image\/svg\+xml;base64,/); + expect(file.url).toBe(local.url); + }); + + it("caches remote assets with deterministic keys", async () => { + const cacheDir = join(tempDir, "asset-cache"); + const { server, url } = await serveAsset("image/png", Buffer.from([0x89, 0x50, 0x4e, 0x47])); + try { + const first = await resolveAssetSource(url, { cacheDir }); + const second = await resolveAssetSource(url, { cacheDir }); + + expect(first.cache).toMatchObject({ hit: false, key: expect.any(String) }); + expect(second.cache).toMatchObject({ hit: true, key: first.cache?.key }); + expect(second.url).toBe(first.url); + } finally { + server.close(); + } + }); + + it("reports missing, unsupported, huge, and transformed assets", async () => { + const missing = await resolveAssetSource("missing.png", { baseDir: tempDir }); + const unsupportedPath = join(tempDir, "asset.txt"); + await writeFile(unsupportedPath, "hello"); + const unsupported = await resolveAssetSource(unsupportedPath, { maxBytes: 2, transform: { width: 24 } }); + + expect(missing.diagnostics).toMatchObject([{ code: "ASSET_MISSING" }]); + expect(unsupported.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "ASSET_UNSUPPORTED_FORMAT" }), + expect.objectContaining({ code: "ASSET_TOO_LARGE" }), + expect.objectContaining({ code: "ASSET_TRANSFORM_UNSUPPORTED" }), + ])); + }); + + it("normalizes SVG dimensions when requested", async () => { + const result = await resolveAssetSource("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E", { + transform: { width: 320, height: 180 }, + }); + + expect(Buffer.from(result.url.split(",")[1] ?? "", "base64").toString("utf8")).toContain('width="320"'); + expect(Buffer.from(result.url.split(",")[1] ?? "", "base64").toString("utf8")).toContain('height="180"'); + }); +}); + +async function serveAsset(contentType: string, body: Buffer): Promise<{ server: Server; url: string }> { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": contentType }); + response.end(body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Server address was not available."); + return { server, url: `http://127.0.0.1:${address.port}/asset.png` }; +}