import { getSignedAssetReadUrl, resolveAssetReadUrl, shouldResolveAssetReadUrl, } from '../../services/assetReadUrlService'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; import { normalizeProjectCoverBackgroundColor, PROJECT_COVER_SNAPSHOT_SIZE, type ProjectCoverDrawableLayer, type ProjectCoverSnapshotViewportSize, resolveProjectCoverDrawableLayers, resolveProjectCoverPlacement, } from './ImageCanvasProjectCoverSnapshotModel'; const PROJECT_COVER_IMAGE_LOAD_CONCURRENCY = 6; const PROJECT_COVER_SNAPSHOT_MIME_TYPE = 'image/webp'; const PROJECT_COVER_SNAPSHOT_WEBP_QUALITY = 0.82; type ProjectCoverDrawItem = { layer: ProjectCoverDrawableLayer; placement: ReturnType; }; function placementIntersectsCover( placement: ProjectCoverDrawItem['placement'], ) { return ( placement.x < PROJECT_COVER_SNAPSHOT_SIZE.width && placement.y < PROJECT_COVER_SNAPSHOT_SIZE.height && placement.x + placement.width > 0 && placement.y + placement.height > 0 ); } async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) { if (layer.objectKey) { return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, { cacheVersion: layer.cacheVersion, }); } if (shouldResolveAssetReadUrl(layer.src)) { return resolveAssetReadUrl(layer.src, { refreshKey: layer.cacheVersion, }); } return layer.src; } async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { const src = await resolveProjectCoverImageUrl(layer); const image = await new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => resolve(image); image.onerror = () => reject(new Error('project cover image load failed')); image.src = src; }); return image; } async function loadProjectCoverDrawItems(items: ProjectCoverDrawItem[]) { const loadedImages = new Array(items.length).fill( null, ); let nextIndex = 0; const loadNext = async () => { while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; try { const item = items[index]; if (item) { loadedImages[index] = await loadProjectCoverImage(item.layer); } } catch { // Individual layer load failures should not block other cover layers. } } }; await Promise.all( Array.from( { length: Math.min(PROJECT_COVER_IMAGE_LOAD_CONCURRENCY, items.length) }, () => loadNext(), ), ); return loadedImages; } function canvasToBlob(canvas: HTMLCanvasElement) { return new Promise((resolve) => { canvas.toBlob( (blob) => resolve(blob), PROJECT_COVER_SNAPSHOT_MIME_TYPE, PROJECT_COVER_SNAPSHOT_WEBP_QUALITY, ); }); } export async function createProjectCoverSnapshotBlob({ layers, viewport, viewportSize, backgroundColor, }: { layers: readonly CanvasLayer[]; viewport: CanvasViewport; viewportSize: ProjectCoverSnapshotViewportSize; backgroundColor: string; }) { if (typeof document === 'undefined') { return null; } const drawableLayers = resolveProjectCoverDrawableLayers(layers); if (drawableLayers.length === 0) { return null; } const drawItems = drawableLayers .map((layer) => ({ layer, placement: resolveProjectCoverPlacement(viewport, viewportSize, layer), })) .filter(({ placement }) => placementIntersectsCover(placement)); const canvas = document.createElement('canvas'); canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width; canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height; const context = canvas.getContext('2d'); if (!context || typeof canvas.toBlob !== 'function') { return null; } context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); context.fillRect(0, 0, canvas.width, canvas.height); if (drawItems.length === 0) { return canvasToBlob(canvas); } const loadedImages = await loadProjectCoverDrawItems(drawItems); let drawnLayerCount = 0; drawItems.forEach(({ placement }, index) => { const image = loadedImages[index]; if (!image) { return; } context.drawImage( image, placement.x, placement.y, placement.width, placement.height, ); drawnLayerCount += 1; }); if (drawnLayerCount === 0) { return null; } return canvasToBlob(canvas); }