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 const PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE = { width: 1280, height: 960, } as const; export type ProjectCoverSnapshotViewportSize = { width: number; height: number; }; export type ProjectCoverDrawableLayer = { id: string; src: string; objectKey: string | null; cacheVersion: string; 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, ), }; } export function normalizeProjectCoverSnapshotViewport({ viewport, viewportSize, }: { viewport: CanvasViewport; viewportSize: ProjectCoverSnapshotViewportSize; }) { const sourceViewportSize = { width: positiveNumberOrFallback( viewportSize.width, PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width, ), height: positiveNumberOrFallback( viewportSize.height, PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height, ), }; const normalizedViewportSize = { width: Math.max( PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width, sourceViewportSize.width, ), height: Math.max( PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height, sourceViewportSize.height, ), }; return { viewport: { x: numberOrFallback(viewport.x, 0) + (normalizedViewportSize.width - sourceViewportSize.width) / 2, y: numberOrFallback(viewport.y, 0) + (normalizedViewportSize.height - sourceViewportSize.height) / 2, scale: numberOrFallback(viewport.scale, 1), }, viewportSize: normalizedViewportSize, }; } export 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), cacheVersion: `${layer.taskId ?? layer.resourceId}:0`, }; } return null; } const snapshotSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); if (!snapshotSrc) { return null; } return { src: snapshotSrc, objectKey: layer.thumbnailSrc?.trim() ? null : normalizeObjectKey(layer.objectKey), cacheVersion: layer.thumbnailSrc?.trim() ? `${layer.taskId ?? layer.resourceId}:poster` : (layer.taskId ?? layer.resourceId), }; } 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, cacheVersion: source.cacheVersion, 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, }; } export 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, }; }