import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; export const PROJECT_COVER_SNAPSHOT_ASSET_KIND = 'project-cover-snapshot'; export const PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND = 'editor_project_cover_snapshot'; export const PROJECT_COVER_SNAPSHOT_SIZE = { width: 320, height: 240 } as const; export const PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE = { width: 900, height: 640, } as const; type ProjectCoverDrawableLayer = { id: string; src: string; objectKey: string | null; x: number; y: number; width: number; height: number; zIndex: number; }; function numberOrFallback(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback; } function normalizeObjectKey(value: string | null | undefined) { const normalized = value?.trim().replace(/^\/+/u, '') ?? ''; return normalized || null; } function resolveLayerSnapshotSource(layer: CanvasLayer) { if (layer.hidden || layer.mediaType === 'audio') { return null; } if (layer.mediaType === 'image-sequence') { const frame = layer.imageSequenceFrames?.[0]; const frameSrc = frame?.imageSrc?.trim(); if (frameSrc) { return { src: frameSrc, objectKey: normalizeObjectKey(frame?.objectKey), }; } } const snapshotSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); if (!snapshotSrc) { return null; } return { src: snapshotSrc, objectKey: layer.thumbnailSrc?.trim() ? null : normalizeObjectKey(layer.objectKey), }; } export function resolveProjectCoverDrawableLayers( layers: readonly CanvasLayer[], ): ProjectCoverDrawableLayer[] { return layers .map((layer) => { const source = resolveLayerSnapshotSource(layer); if (!source) { return null; } return { id: layer.id, src: source.src, objectKey: source.objectKey, x: numberOrFallback(layer.x, 0), y: numberOrFallback(layer.y, 0), width: Math.max(1, numberOrFallback(layer.width, layer.originalWidth)), height: Math.max(1, numberOrFallback(layer.height, layer.originalHeight)), zIndex: numberOrFallback(layer.zIndex, 0), } satisfies ProjectCoverDrawableLayer; }) .filter((layer): layer is ProjectCoverDrawableLayer => Boolean(layer)) .sort((left, right) => left.zIndex - right.zIndex); } export function buildProjectCoverSnapshotSignature({ layers, viewport, }: { layers: readonly CanvasLayer[]; viewport: CanvasViewport; }) { const coverLayers = resolveProjectCoverDrawableLayers(layers); if (coverLayers.length === 0) { return null; } return JSON.stringify({ viewport: { x: numberOrFallback(viewport.x, 0), y: numberOrFallback(viewport.y, 0), scale: numberOrFallback(viewport.scale, 1), }, layers: coverLayers, }); } function resolveProjectCoverPlacement( viewport: CanvasViewport, layer: ProjectCoverDrawableLayer, ) { const safeScale = viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1; const viewportCenterX = (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / 2 - viewport.x) / safeScale; const viewportCenterY = (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / 2 - viewport.y) / safeScale; const worldPreviewWidth = PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / safeScale; const worldPreviewHeight = PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / safeScale; const previewMinX = viewportCenterX - worldPreviewWidth / 2; const previewMinY = viewportCenterY - worldPreviewHeight / 2; const scale = Math.min( PROJECT_COVER_SNAPSHOT_SIZE.width / worldPreviewWidth, PROJECT_COVER_SNAPSHOT_SIZE.height / worldPreviewHeight, ); const offsetX = -previewMinX * scale; const offsetY = -previewMinY * scale; return { x: offsetX + layer.x * scale, y: offsetY + layer.y * scale, width: layer.width * scale, height: layer.height * scale, }; } async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { const src = layer.objectKey ? await getSignedAssetReadUrl({ objectKey: layer.objectKey }) : layer.src; return 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; }); } function canvasToBlob(canvas: HTMLCanvasElement) { return new Promise((resolve) => { canvas.toBlob((blob) => resolve(blob), 'image/png'); }); } export async function createProjectCoverSnapshotBlob({ layers, viewport, }: { layers: readonly CanvasLayer[]; viewport: CanvasViewport; }) { if (typeof document === 'undefined') { return null; } const coverLayers = resolveProjectCoverDrawableLayers(layers); if (coverLayers.length === 0) { return null; } 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.clearRect(0, 0, canvas.width, canvas.height); let drawnLayerCount = 0; for (const layer of coverLayers) { try { const image = await loadProjectCoverImage(layer); const placement = resolveProjectCoverPlacement(viewport, layer); context.drawImage( image, placement.x, placement.y, placement.width, placement.height, ); drawnLayerCount += 1; } catch { // Individual layer load failures should not block a cover from other layers. } } if (drawnLayerCount === 0) { return null; } return canvasToBlob(canvas); }