import { ref, computed, type Ref } from 'vue'; import type { SpritePreview, SpriteRegion, GridSplitOptions, AutoDetectOptions, SpriteWorkerResponse } from '@/types/spritesheet'; /** * Composable for spritesheet splitting logic * Handles grid-based and auto-detection sprite extraction */ export function useSpritesheetSplitter() { const isProcessing = ref(false); const previewSprites: Ref = ref([]); const worker: Ref = ref(null); /** * Split image into grid cells */ async function splitByGrid(img: HTMLImageElement, options: GridSplitOptions): Promise { const { cellWidth, cellHeight, preserveCellSize, removeEmpty } = options; if (cellWidth <= 0 || cellHeight <= 0) { return []; } const cols = Math.floor(img.width / cellWidth); const rows = Math.floor(img.height / cellHeight); const sprites: SpritePreview[] = []; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const cropCanvas = document.createElement('canvas'); const cropCtx = cropCanvas.getContext('2d'); if (!ctx || !cropCtx) return []; canvas.width = cellWidth; canvas.height = cellHeight; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { ctx.clearRect(0, 0, cellWidth, cellHeight); ctx.drawImage(img, col * cellWidth, row * cellHeight, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight); const isEmpty = removeEmpty ? isCanvasEmpty(ctx, cellWidth, cellHeight) : false; if (!removeEmpty || !isEmpty) { let url: string; let x = 0; let y = 0; let width = cellWidth; let height = cellHeight; if (preserveCellSize) { // Keep full cell with transparent padding url = canvas.toDataURL('image/png'); } else { // Crop to sprite bounds const bounds = getSpriteBounds(ctx, cellWidth, cellHeight); if (bounds) { x = bounds.x; y = bounds.y; width = bounds.width; height = bounds.height; cropCanvas.width = width; cropCanvas.height = height; cropCtx.clearRect(0, 0, width, height); cropCtx.drawImage(canvas, x, y, width, height, 0, 0, width, height); url = cropCanvas.toDataURL('image/png'); } else { url = canvas.toDataURL('image/png'); } } sprites.push({ url, x, y, width, height, isEmpty }); } } } return sprites; } /** * Detect sprites using web worker for irregular layouts */ async function detectSprites(img: HTMLImageElement, options: AutoDetectOptions): Promise { const { sensitivity, removeEmpty } = options; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return []; canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, img.width, img.height); // Initialize worker lazily if (!worker.value) { try { worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' }); } catch (error) { console.error('Failed to create worker:', error); return []; } } return new Promise(resolve => { const w = worker.value!; const timeout = setTimeout(() => { w.removeEventListener('message', handleMessage); console.warn('Worker timeout'); resolve([]); }, 30000); const handleMessage = async (e: MessageEvent) => { clearTimeout(timeout); w.removeEventListener('message', handleMessage); if (e.data.type === 'spritesDetected') { const sprites = await processDetectedSprites(img, e.data.sprites, e.data.backgroundColor, removeEmpty); resolve(sprites); } }; w.addEventListener('message', handleMessage); w.postMessage({ type: 'detectIrregularSprites', imageData, sensitivity, maxSize: 2048, }); }); } /** * Process sprites detected by worker into preview format */ async function processDetectedSprites(img: HTMLImageElement, regions: SpriteRegion[], backgroundColor: [number, number, number, number], removeEmpty: boolean): Promise { if (!regions?.length) return []; const sprites: SpritePreview[] = []; const sourceCanvas = document.createElement('canvas'); const sourceCtx = sourceCanvas.getContext('2d'); const spriteCanvas = document.createElement('canvas'); const spriteCtx = spriteCanvas.getContext('2d'); if (!sourceCtx || !spriteCtx) return []; sourceCanvas.width = img.width; sourceCanvas.height = img.height; sourceCtx.drawImage(img, 0, 0); for (const region of regions) { const { x, y, width, height } = region; if (width <= 0 || height <= 0) continue; spriteCanvas.width = width; spriteCanvas.height = height; spriteCtx.clearRect(0, 0, width, height); spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height); // Remove background color removeBackground(spriteCtx, width, height, backgroundColor); const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false; if (!removeEmpty || !isEmpty) { sprites.push({ url: spriteCanvas.toDataURL('image/png'), x, y, width, height, isEmpty, }); } } return sprites; } /** * Get bounding box of non-transparent pixels */ function getSpriteBounds(ctx: CanvasRenderingContext2D, width: number, height: number): { x: number; y: number; width: number; height: number } | null { const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; let minX = width, minY = height, maxX = 0, maxY = 0; let hasContent = false; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const alpha = data[(y * width + x) * 4 + 3]; if (alpha > 10) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); hasContent = true; } } } if (!hasContent) return null; // Add small padding const pad = 1; return { x: Math.max(0, minX - pad), y: Math.max(0, minY - pad), width: Math.min(width - Math.max(0, minX - pad), maxX - minX + 1 + pad * 2), height: Math.min(height - Math.max(0, minY - pad), maxY - minY + 1 + pad * 2), }; } /** * Check if canvas is empty (all transparent or same color) */ function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean { const data = ctx.getImageData(0, 0, width, height).data; let allTransparent = true; let allSameColor = true; const [firstR, firstG, firstB, firstA] = [data[0], data[1], data[2], data[3]]; for (let i = 0; i < data.length; i += 4) { if (data[i + 3] > 10) allTransparent = false; if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) { allSameColor = false; } if (!allTransparent && !allSameColor) break; } return allTransparent || allSameColor; } /** * Remove background color from sprite, making it transparent */ function removeBackground(ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: [number, number, number, number]): void { const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; const [bgR, bgG, bgB, bgA] = bgColor; const tolerance = 30; for (let i = 0; i < data.length; i += 4) { const a = data[i + 3]; if (a < 10) { data[i + 3] = 0; continue; } const rDiff = data[i] - bgR; const gDiff = data[i + 1] - bgG; const bDiff = data[i + 2] - bgB; const dist = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); if (dist <= tolerance && Math.abs(a - bgA) <= 40) { data[i + 3] = 0; } } ctx.putImageData(imageData, 0, 0); } /** * Cleanup worker on unmount */ function cleanup() { if (worker.value) { worker.value.terminate(); worker.value = null; } } /** * Calculate suggested cell size based on image dimensions */ function getSuggestedCellSize(width: number, height: number): { width: number; height: number } { const commonSizes = [256, 192, 128, 96, 64, 48, 32, 16]; let cellWidth = 64; let cellHeight = 64; for (const size of commonSizes) { if (width % size === 0 && width / size <= 16 && width / size >= 1) { cellWidth = size; break; } } for (const size of commonSizes) { if (height % size === 0 && height / size <= 16 && height / size >= 1) { cellHeight = size; break; } } return { width: cellWidth, height: cellHeight }; } return { isProcessing, previewSprites, splitByGrid, detectSprites, getSuggestedCellSize, cleanup, }; }