import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; import { DEFAULT_CANVAS_BACKGROUND_COLOR, normalizeCanvasBackgroundHex, } from './ImageCanvasEditorModel'; 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 type ProjectCoverSnapshotViewportSize = { width: number; height: number; }; 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 positiveNumberOrFallback(value: number, fallback: number) { return Number.isFinite(value) && value > 0 ? value : fallback; } function normalizeProjectCoverViewportSize( viewportSize: ProjectCoverSnapshotViewportSize, ) { return { width: positiveNumberOrFallback( viewportSize.width, PROJECT_COVER_SNAPSHOT_SIZE.width, ), height: positiveNumberOrFallback( viewportSize.height, PROJECT_COVER_SNAPSHOT_SIZE.height, ), }; } function normalizeProjectCoverBackgroundColor(backgroundColor: string) { return ( normalizeCanvasBackgroundHex(backgroundColor) ?? DEFAULT_CANVAS_BACKGROUND_COLOR ); } 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, viewportSize, backgroundColor, }: { layers: readonly CanvasLayer[]; // we may call it camera viewport: CanvasViewport; viewportSize: ProjectCoverSnapshotViewportSize; backgroundColor: string; }) { const coverLayers = resolveProjectCoverDrawableLayers(layers); if (coverLayers.length === 0) { return null; } const normalizedViewportSize = normalizeProjectCoverViewportSize(viewportSize); return JSON.stringify({ viewport: { x: numberOrFallback(viewport.x, 0), y: numberOrFallback(viewport.y, 0), scale: numberOrFallback(viewport.scale, 1), }, viewportSize: normalizedViewportSize, backgroundColor: normalizeProjectCoverBackgroundColor(backgroundColor), layers: coverLayers, }); } export function resolveProjectCoverSnapshotFrame({ viewport, viewportSize, }: { viewport: CanvasViewport; viewportSize: ProjectCoverSnapshotViewportSize; }) { const normalizedViewportSize = normalizeProjectCoverViewportSize(viewportSize); const renderScale = viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1; const frameScale = Math.max( PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width, PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height, ); return { viewportX: numberOrFallback(viewport.x, 0), viewportY: numberOrFallback(viewport.y, 0), renderScale, frameScale, offsetX: (PROJECT_COVER_SNAPSHOT_SIZE.width - normalizedViewportSize.width * frameScale) / 2, offsetY: (PROJECT_COVER_SNAPSHOT_SIZE.height - normalizedViewportSize.height * frameScale) / 2, }; } function resolveProjectCoverPlacement( viewport: CanvasViewport, viewportSize: ProjectCoverSnapshotViewportSize, layer: ProjectCoverDrawableLayer, ) { const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize }); return { x: frame.offsetX + (frame.viewportX + layer.x * frame.renderScale) * frame.frameScale, y: frame.offsetY + (frame.viewportY + layer.y * frame.renderScale) * frame.frameScale, width: layer.width * frame.renderScale * frame.frameScale, height: layer.height * frame.renderScale * frame.frameScale, }; } 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, viewportSize, backgroundColor, }: { layers: readonly CanvasLayer[]; viewport: CanvasViewport; viewportSize: ProjectCoverSnapshotViewportSize; backgroundColor: string; }) { 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.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); context.fillRect(0, 0, canvas.width, canvas.height); let drawnLayerCount = 0; for (const layer of coverLayers) { try { const image = await loadProjectCoverImage(layer); const placement = resolveProjectCoverPlacement( viewport, viewportSize, 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); }