[FEAT] Improved splitting
This commit is contained in:
@@ -1,91 +1,92 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split spritesheet">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-48 sm:max-h-64 border border-gray-300 dark:border-gray-600 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="detection-method" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Detection Method</label>
|
||||
<select
|
||||
id="detection-method"
|
||||
v-model="detectionMethod"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
data-rybbit-event="spritesheet-detection-method"
|
||||
>
|
||||
<option value="irregular">Auto-detect</option>
|
||||
<option value="manual">Manual (specify rows and columns)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'auto' || detectionMethod === 'irregular'" class="space-y-2">
|
||||
<label for="sensitivity" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Detection Sensitivity</label>
|
||||
<input type="range" id="sensitivity" v-model="sensitivity" min="1" max="100" class="w-full dark:accent-blue-400" data-rybbit-event="spritesheet-sensitivity" />
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex justify-between">
|
||||
<span>Low</span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'manual'" class="space-y-2">
|
||||
<label for="rows" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Rows</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rows"
|
||||
v-model.number="rows"
|
||||
min="1"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
data-rybbit-event="spritesheet-rows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'manual'" class="space-y-2">
|
||||
<label for="columns" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Columns</label>
|
||||
<input
|
||||
type="number"
|
||||
id="columns"
|
||||
v-model.number="columns"
|
||||
min="1"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
data-rybbit-event="spritesheet-columns"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Preview Image -->
|
||||
<div class="flex items-center justify-center">
|
||||
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-48 sm:max-h-64 border border-gray-300 dark:border-gray-600 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
|
||||
<!-- Detection Mode -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-400 border-gray-300 dark:border-gray-600 rounded" data-rybbit-event="spritesheet-remove-empty" />
|
||||
<label for="remove-empty" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Remove empty sprites (transparent/background color) </label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Mode </label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button @click="detectionMode = 'grid'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'grid' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Grid</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Split by cell size</div>
|
||||
</button>
|
||||
<button @click="detectionMode = 'auto'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'auto' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Auto-detect</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Find individual sprites</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewSprites.length > 0" class="space-y-2">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Preview ({{ previewSprites.length }} sprites)</h3>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-96 overflow-y-auto p-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800">
|
||||
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 dark:border-gray-600 rounded bg-gray-100 dark:bg-gray-700 flex items-center justify-center" :style="{ width: '80px', height: '80px' }">
|
||||
<img :src="sprite.url" alt="Sprite preview" class="max-w-full max-h-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
<!-- Grid Mode Options -->
|
||||
<div v-if="detectionMode === 'grid'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label for="cell-width" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Cell Width (px) </label>
|
||||
<input type="number" id="cell-width" v-model.number="cellWidth" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="cell-height" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Cell Height (px) </label>
|
||||
<input type="number" id="cell-height" v-model.number="cellHeight" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="preserve-cell-size" v-model="preserveCellSize" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label for="preserve-cell-size" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Preserve cell size (keep transparent padding) </label>
|
||||
</div>
|
||||
|
||||
<div v-if="imageElement" class="text-xs text-gray-500 dark:text-gray-400">Image: {{ imageElement.width }}×{{ imageElement.height }}px → {{ gridCols }}×{{ gridRows }} cells</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-detect Options -->
|
||||
<div v-if="detectionMode === 'auto'" class="space-y-2">
|
||||
<label for="sensitivity" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Sensitivity </label>
|
||||
<input type="range" id="sensitivity" v-model.number="sensitivity" min="1" max="100" class="w-full dark:accent-blue-400" />
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex justify-between">
|
||||
<span>Low (more sprites)</span>
|
||||
<span>High (fewer sprites)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Options -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label for="remove-empty" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Remove empty sprites </label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-if="previewSprites.length > 0 || isProcessing" class="space-y-2">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<span v-if="isProcessing">Detecting sprites...</span>
|
||||
<span v-else>Preview ({{ previewSprites.length }} sprites)</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-80 overflow-y-auto p-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 flex items-center justify-center aspect-square">
|
||||
<img :src="sprite.url" alt="Sprite" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end space-y-3 space-y-reverse sm:space-y-0 sm:space-x-3">
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="cancel"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800"
|
||||
data-rybbit-event="spritesheet-cancel"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800"
|
||||
:disabled="previewSprites.length === 0 || isProcessing"
|
||||
data-rybbit-event="spritesheet-split"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Split spritesheet
|
||||
{{ isProcessing ? 'Processing...' : 'Split spritesheet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,19 +94,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
import { ref, watch, computed, onUnmounted } from 'vue';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import { useSpritesheetSplitter } from '@/composables/useSpritesheetSplitter';
|
||||
import type { SpriteFile } from '@/types/sprites';
|
||||
|
||||
interface SpritePreview {
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
import type { DetectionMode, SpritePreview } from '@/types/spritesheet';
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
@@ -115,93 +109,80 @@
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
|
||||
(e: 'split', sprites: SpriteFile[]): void;
|
||||
}>();
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
const splitter = useSpritesheetSplitter();
|
||||
|
||||
// State
|
||||
const detectionMethod = ref<'manual' | 'auto' | 'irregular'>('irregular');
|
||||
const rows = ref(1);
|
||||
const columns = ref(1);
|
||||
const detectionMode = ref<DetectionMode>('grid');
|
||||
const cellWidth = ref(64);
|
||||
const cellHeight = ref(64);
|
||||
const sensitivity = ref(50);
|
||||
const removeEmpty = ref(true);
|
||||
const preserveCellSize = ref(false);
|
||||
const previewSprites = ref<SpritePreview[]>([]);
|
||||
const isProcessing = ref(false);
|
||||
const imageElement = ref<HTMLImageElement | null>(null);
|
||||
const irregularWorker = ref<Worker | null>(null);
|
||||
|
||||
// Cache for sprite detection results
|
||||
const detectionCache = new Map<string, SpritePreview[]>();
|
||||
// Computed
|
||||
const gridCols = computed(() => (imageElement.value && cellWidth.value > 0 ? Math.floor(imageElement.value.width / cellWidth.value) : 0));
|
||||
|
||||
// Generate cache key for current detection settings
|
||||
function getCacheKey(url: string, method: string, sensitivity: number, removeEmpty: boolean): string {
|
||||
return `${url}-${method}-${sensitivity}-${removeEmpty}`;
|
||||
}
|
||||
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
|
||||
|
||||
// Load the image when the component is mounted or the URL changes
|
||||
watch(() => props.imageUrl, loadImage, { immediate: true });
|
||||
// Load image and set initial cell size
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
url => {
|
||||
if (!url) return;
|
||||
|
||||
function loadImage() {
|
||||
if (!props.imageUrl) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageElement.value = img;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageElement.value = img;
|
||||
// Set suggested cell size
|
||||
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
|
||||
cellWidth.value = suggested.width;
|
||||
cellHeight.value = suggested.height;
|
||||
|
||||
// Set default rows and columns based on image dimensions
|
||||
// This is a simple heuristic - for pixel art, we might want to detect sprite size
|
||||
const aspectRatio = img.width / img.height;
|
||||
generatePreview();
|
||||
};
|
||||
img.src = url;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape orientation - likely more columns than rows
|
||||
columns.value = Math.min(Math.ceil(Math.sqrt(aspectRatio * 4)), 8);
|
||||
rows.value = Math.ceil(4 / columns.value);
|
||||
} else {
|
||||
// Portrait orientation - likely more rows than columns
|
||||
rows.value = Math.min(Math.ceil(Math.sqrt(4 / aspectRatio)), 8);
|
||||
columns.value = Math.ceil(4 / rows.value);
|
||||
}
|
||||
|
||||
// Generate initial preview
|
||||
// Regenerate preview when options change
|
||||
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
|
||||
if (imageElement.value) {
|
||||
generatePreview();
|
||||
};
|
||||
img.src = props.imageUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Generate preview of split sprites with caching
|
||||
// Generate preview
|
||||
async function generatePreview() {
|
||||
if (!imageElement.value) return;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(props.imageUrl, detectionMethod.value, sensitivity.value, removeEmpty.value);
|
||||
|
||||
if (detectionCache.has(cacheKey)) {
|
||||
previewSprites.value = detectionCache.get(cacheKey)!;
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
previewSprites.value = [];
|
||||
|
||||
try {
|
||||
const img = imageElement.value;
|
||||
|
||||
if (detectionMethod.value === 'auto') {
|
||||
await autoDetectSprites(img);
|
||||
} else if (detectionMethod.value === 'irregular') {
|
||||
await detectIrregularSprites(img);
|
||||
if (detectionMode.value === 'grid') {
|
||||
previewSprites.value = await splitter.splitByGrid(img, {
|
||||
cellWidth: cellWidth.value,
|
||||
cellHeight: cellHeight.value,
|
||||
preserveCellSize: preserveCellSize.value,
|
||||
removeEmpty: removeEmpty.value,
|
||||
});
|
||||
} else {
|
||||
await splitSpritesheet(img, rows.value, columns.value);
|
||||
previewSprites.value = await splitter.detectSprites(img, {
|
||||
sensitivity: sensitivity.value,
|
||||
removeEmpty: removeEmpty.value,
|
||||
});
|
||||
}
|
||||
|
||||
// Cache results (limit cache size to prevent memory issues)
|
||||
if (detectionCache.size > 10) {
|
||||
const firstKey = detectionCache.keys().next().value;
|
||||
detectionCache.delete(firstKey || '');
|
||||
}
|
||||
detectionCache.set(cacheKey, previewSprites.value);
|
||||
} catch (error) {
|
||||
console.error('Error generating preview:', error);
|
||||
} finally {
|
||||
@@ -209,636 +190,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getSpriteBoundingBox(ctx: CanvasRenderingContext2D, width: number, height: number) {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
let minX = width;
|
||||
let minY = height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let hasContent = false;
|
||||
|
||||
// Scan through all pixels to find the bounding box
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
// Check if pixel is not transparent (alpha > 0)
|
||||
if (data[idx + 3] > 10) {
|
||||
// Allow some tolerance for compression artifacts
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-transparent pixels found, return null
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return bounding box with a small padding, ensuring it stays within bounds
|
||||
const bx = Math.max(0, minX - 1);
|
||||
const by = Math.max(0, minY - 1);
|
||||
const bw = Math.min(width - bx, maxX - minX + 3); // +1 for inclusive bounds, +2 for padding
|
||||
const bh = Math.min(height - by, maxY - minY + 3);
|
||||
|
||||
return {
|
||||
x: bx,
|
||||
y: by,
|
||||
width: Math.max(1, bw),
|
||||
height: Math.max(1, bh),
|
||||
};
|
||||
}
|
||||
|
||||
// Split spritesheet manually based on rows and columns
|
||||
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
|
||||
const safeColumns = Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 1;
|
||||
const safeRows = Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1;
|
||||
const spriteWidth = Math.max(1, Math.floor(img.width / safeColumns));
|
||||
const spriteHeight = Math.max(1, Math.floor(img.height / safeRows));
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
// Create a canvas for processing the full sprite
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Create a second canvas for the cropped sprite
|
||||
const croppedCanvas = document.createElement('canvas');
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
if (!ctx || !croppedCtx) return;
|
||||
|
||||
canvas.width = spriteWidth;
|
||||
canvas.height = spriteHeight;
|
||||
|
||||
// Split the image into individual sprites
|
||||
for (let row = 0; row < safeRows; row++) {
|
||||
for (let col = 0; col < safeColumns; col++) {
|
||||
// Clear the canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw the portion of the spritesheet
|
||||
ctx.drawImage(img, col * spriteWidth, row * spriteHeight, spriteWidth, spriteHeight, 0, 0, spriteWidth, spriteHeight);
|
||||
|
||||
// Check if the sprite is empty (all transparent or same color)
|
||||
const isEmpty = removeEmpty.value ? isCanvasEmpty(ctx, spriteWidth, spriteHeight) : false;
|
||||
|
||||
// If we're not removing empty sprites or the sprite is not empty
|
||||
if (!removeEmpty.value || !isEmpty) {
|
||||
// Get bounding box of non-transparent pixels
|
||||
const boundingBox = getSpriteBoundingBox(ctx, spriteWidth, spriteHeight);
|
||||
|
||||
let url;
|
||||
let x = 0; // Default position (will be updated if we have a bounding box)
|
||||
let y = 0;
|
||||
let width = spriteWidth;
|
||||
let height = spriteHeight;
|
||||
|
||||
if (boundingBox) {
|
||||
// The key change: preserve the original position where the sprite was found
|
||||
x = boundingBox.x;
|
||||
y = boundingBox.y;
|
||||
width = boundingBox.width;
|
||||
height = boundingBox.height;
|
||||
|
||||
// Set dimensions for the cropped sprite
|
||||
croppedCanvas.width = boundingBox.width;
|
||||
croppedCanvas.height = boundingBox.height;
|
||||
|
||||
// Draw only the non-transparent part
|
||||
croppedCtx.clearRect(0, 0, croppedCanvas.width, croppedCanvas.height);
|
||||
croppedCtx.drawImage(canvas, boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height, 0, 0, boundingBox.width, boundingBox.height);
|
||||
|
||||
// Convert to data URL
|
||||
url = croppedCanvas.toDataURL('image/png');
|
||||
} else {
|
||||
// No non-transparent pixels found, use the original sprite
|
||||
url = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
sprites.push({
|
||||
url,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewSprites.value = sprites;
|
||||
}
|
||||
|
||||
// Auto-detect sprites based on transparency/color differences
|
||||
async function autoDetectSprites(img: HTMLImageElement) {
|
||||
// This is a simplified implementation
|
||||
// A more sophisticated algorithm would analyze the image to find sprite boundaries
|
||||
|
||||
// For now, we'll use a simple approach:
|
||||
// 1. Try to detect the sprite size by looking for repeating patterns
|
||||
// 2. Then use that size to split the spritesheet
|
||||
|
||||
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);
|
||||
|
||||
// Get image data for analysis
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Simple detection of sprite size based on transparency patterns
|
||||
// This is a very basic implementation and might not work for all spritesheets
|
||||
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
|
||||
|
||||
if (detectedWidth > 0 && detectedHeight > 0) {
|
||||
// Sanity thresholds to avoid absurdly tiny tiles/huge counts
|
||||
const MIN_TILE = 8;
|
||||
const MAX_SPRITES = 1024;
|
||||
if (detectedWidth < MIN_TILE || detectedHeight < MIN_TILE) {
|
||||
// Fallback if tile is too small
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
const detectedRows = Math.max(1, Math.floor(img.height / detectedHeight));
|
||||
const detectedColumns = Math.max(1, Math.floor(img.width / detectedWidth));
|
||||
|
||||
// If the detected combination is unreasonably high, fallback to estimate
|
||||
if (detectedRows * detectedColumns > MAX_SPRITES) {
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the detected size to split the spritesheet
|
||||
await splitSpritesheet(img, detectedRows, detectedColumns);
|
||||
} else {
|
||||
// Fallback to manual splitting with a reasonable guess based on sensitivity
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(8, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to detect sprite size based on transparency/color gutters and edge periodicity
|
||||
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
|
||||
// Map sensitivity (1-100) to thresholds:
|
||||
// Higher sensitivity -> allows stricter background matching and lower gutter proportion thresholds
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
|
||||
// Background/color thresholds
|
||||
const alphaBgThresh = Math.round(255 * (0.15 + (100 - s) * 0.001)); // 15%-25% depending on sensitivity
|
||||
const colorTol = Math.round(10 + (100 - s) * 0.8); // 10..90 Euclidean approx
|
||||
const gutterPropThresh = 0.92 - s * 0.004; // 0.92 down to ~0.52
|
||||
|
||||
function getPixel(x: number, y: number) {
|
||||
const idx = (y * width + x) * 4;
|
||||
return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]] as [number, number, number, number];
|
||||
}
|
||||
|
||||
// Estimate background color from corners (median of corners)
|
||||
const corners: [number, number, number, number][] = [getPixel(0, 0), getPixel(width - 1, 0), getPixel(0, height - 1), getPixel(width - 1, height - 1)];
|
||||
function median(arr: number[]) {
|
||||
const a = arr.slice().sort((a, b) => a - b);
|
||||
const mid = (a.length - 1) / 2;
|
||||
return (a[Math.floor(mid)] + a[Math.ceil(mid)]) / 2;
|
||||
}
|
||||
const bg = [median(corners.map(c => c[0])), median(corners.map(c => c[1])), median(corners.map(c => c[2])), median(corners.map(c => c[3]))] as [number, number, number, number];
|
||||
|
||||
function isBg(r: number, g: number, b: number, a: number) {
|
||||
if (a <= alphaBgThresh) return true;
|
||||
const dr = r - bg[0];
|
||||
const dg = g - bg[1];
|
||||
const db = b - bg[2];
|
||||
// Use Manhattan distance approximation for speed
|
||||
const manhattan = Math.abs(dr) + Math.abs(dg) + Math.abs(db);
|
||||
// Normalize approx to 0..~765 and compare to scaled tolerance
|
||||
return manhattan <= colorTol * 3;
|
||||
}
|
||||
|
||||
// Sample stride to speed up scanning large sheets
|
||||
const rowSample = Math.max(1, Math.floor(height / 64));
|
||||
const colSample = Math.max(1, Math.floor(width / 64));
|
||||
|
||||
// Compute background proportion per column and row
|
||||
const colBgProp: number[] = new Array(width).fill(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let y = 0; y < height; y += rowSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
colBgProp[x] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
const rowBgProp: number[] = new Array(height).fill(0);
|
||||
for (let y = 0; y < height; y++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let x = 0; x < width; x += colSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
rowBgProp[y] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
function extractRuns(bgProp: number[]): { emptyRuns: [number, number][]; segSizes: number[] } {
|
||||
const emptyRuns: [number, number][] = [];
|
||||
const segSizes: number[] = [];
|
||||
let inEmpty = false;
|
||||
let runStart = 0;
|
||||
let lastSeparatorEnd = -1;
|
||||
for (let i = 0; i < bgProp.length; i++) {
|
||||
const isEmpty = bgProp[i] >= gutterPropThresh;
|
||||
if (isEmpty && !inEmpty) {
|
||||
inEmpty = true;
|
||||
runStart = i;
|
||||
if (lastSeparatorEnd >= 0) {
|
||||
const seg = runStart - lastSeparatorEnd - 1;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
} else if (!isEmpty && inEmpty) {
|
||||
inEmpty = false;
|
||||
emptyRuns.push([runStart, i - 1]);
|
||||
lastSeparatorEnd = i - 1;
|
||||
}
|
||||
}
|
||||
if (inEmpty) {
|
||||
emptyRuns.push([runStart, bgProp.length - 1]);
|
||||
lastSeparatorEnd = bgProp.length - 1;
|
||||
}
|
||||
// Trailing segment after last empty run
|
||||
if (lastSeparatorEnd >= 0 && lastSeparatorEnd < bgProp.length - 1) {
|
||||
const seg = bgProp.length - 1 - lastSeparatorEnd;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
return { emptyRuns, segSizes };
|
||||
}
|
||||
|
||||
function modeWithTolerance(values: number[], tol = 2): number {
|
||||
if (values.length === 0) return 0;
|
||||
values.sort((a, b) => a - b);
|
||||
let bestCount = 0;
|
||||
let bestVal = values[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const base = values[i];
|
||||
let count = 1;
|
||||
for (let j = i + 1; j < values.length; j++) {
|
||||
if (Math.abs(values[j] - base) <= tol) count++;
|
||||
else break;
|
||||
}
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestVal = base;
|
||||
}
|
||||
}
|
||||
return bestVal;
|
||||
}
|
||||
|
||||
const colRuns = extractRuns(colBgProp);
|
||||
const rowRuns = extractRuns(rowBgProp);
|
||||
|
||||
let detectedWidth = modeWithTolerance(colRuns.segSizes, 2);
|
||||
let detectedHeight = modeWithTolerance(rowRuns.segSizes, 2);
|
||||
|
||||
// Fallback: use edge periodicity via autocorrelation if gutters not found
|
||||
function edgeAutocorrLength(axis: 'x' | 'y'): number {
|
||||
const maxLen = axis === 'x' ? width : height;
|
||||
const otherLen = axis === 'x' ? height : width;
|
||||
const sampleStepMajor = Math.max(1, Math.floor(maxLen / 512));
|
||||
const sampleStepMinor = Math.max(1, Math.floor(otherLen / 64));
|
||||
const energy: number[] = new Array(maxLen).fill(0);
|
||||
if (axis === 'x') {
|
||||
for (let x = 0; x < maxLen - 1; x += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let y = 0; y < otherLen; y += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x + 1, y);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[x] = e;
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < maxLen - 1; y += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let x = 0; x < otherLen; x += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x, y + 1);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[y] = e;
|
||||
}
|
||||
}
|
||||
const minTile = Math.max(3, Math.floor(Math.min(maxLen / 32, 128)));
|
||||
const maxTile = Math.max(minTile + 1, Math.floor(Math.min(maxLen / 2, 512)));
|
||||
let bestLag = 0;
|
||||
let bestVal = -Infinity;
|
||||
for (let lag = minTile; lag <= maxTile; lag++) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i + lag < energy.length; i++) {
|
||||
const e1 = energy[i] || 0;
|
||||
const e2 = energy[i + lag] || 0;
|
||||
sum += e1 * e2;
|
||||
}
|
||||
if (sum > bestVal) {
|
||||
bestVal = sum;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
return bestLag;
|
||||
}
|
||||
|
||||
if (detectedWidth <= 0 || detectedWidth > width) {
|
||||
const lagX = edgeAutocorrLength('x');
|
||||
if (lagX > 0 && lagX <= width) detectedWidth = lagX;
|
||||
}
|
||||
if (detectedHeight <= 0 || detectedHeight > height) {
|
||||
const lagY = edgeAutocorrLength('y');
|
||||
if (lagY > 0 && lagY <= height) detectedHeight = lagY;
|
||||
}
|
||||
|
||||
// Sanity checks
|
||||
if (!Number.isFinite(detectedWidth) || detectedWidth <= 0 || detectedWidth > width) detectedWidth = 0;
|
||||
if (!Number.isFinite(detectedHeight) || detectedHeight <= 0 || detectedHeight > height) detectedHeight = 0;
|
||||
|
||||
return { detectedWidth, detectedHeight };
|
||||
}
|
||||
|
||||
// Detect irregular sprites using Web Worker
|
||||
async function detectIrregularSprites(img: HTMLImageElement): Promise<void> {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Could not get canvas context');
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Initialize worker lazily using Vite worker syntax
|
||||
if (!irregularWorker.value) {
|
||||
try {
|
||||
irregularWorker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
// Fallback to auto detection if worker fails
|
||||
await autoDetectSprites(img);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const worker = irregularWorker.value!;
|
||||
|
||||
const handleMessage = async (e: MessageEvent) => {
|
||||
clearTimeout(timeout);
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
worker.removeEventListener('error', handleError);
|
||||
|
||||
if (e.data.type === 'spritesDetected') {
|
||||
try {
|
||||
await processDetectedSprites(img, e.data.sprites, e.data.backgroundColor);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set timeout for worker processing
|
||||
const timeout = setTimeout(() => {
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
worker.removeEventListener('error', handleError);
|
||||
console.warn('Worker timeout, falling back to auto detection');
|
||||
// Fallback to auto detection
|
||||
autoDetectSprites(img).then(resolve).catch(reject);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
const handleError = (error: ErrorEvent) => {
|
||||
clearTimeout(timeout);
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
worker.removeEventListener('error', handleError);
|
||||
console.error('Worker error, falling back to auto detection:', error);
|
||||
// Fallback to auto detection
|
||||
autoDetectSprites(img).then(resolve).catch(reject);
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handleMessage);
|
||||
worker.addEventListener('error', handleError);
|
||||
|
||||
try {
|
||||
worker.postMessage({
|
||||
type: 'detectIrregularSprites',
|
||||
imageData,
|
||||
sensitivity: sensitivity.value,
|
||||
maxSize: 2048, // Limit processing size for performance
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
console.error('Failed to post message to worker:', error);
|
||||
// Fallback to auto detection
|
||||
autoDetectSprites(img).then(resolve).catch(reject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process sprites detected by the worker (optimized)
|
||||
async function processDetectedSprites(img: HTMLImageElement, detectedSprites: any[], backgroundColor?: [number, number, number, number]): Promise<void> {
|
||||
if (!detectedSprites?.length) {
|
||||
previewSprites.value = [];
|
||||
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;
|
||||
|
||||
// Setup source canvas once
|
||||
sourceCanvas.width = img.width;
|
||||
sourceCanvas.height = img.height;
|
||||
sourceCtx.drawImage(img, 0, 0);
|
||||
|
||||
// Process sprites in batches to avoid blocking
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < detectedSprites.length; i += batchSize) {
|
||||
const batch = detectedSprites.slice(i, i + batchSize);
|
||||
|
||||
for (const sprite of batch) {
|
||||
const { x, y, width, height } = sprite;
|
||||
|
||||
// Skip invalid sprites
|
||||
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 and make transparent for irregular sprites
|
||||
if (detectionMethod.value === 'irregular' && backgroundColor) {
|
||||
removeBackgroundFromSprite(spriteCtx, width, height, backgroundColor, sensitivity.value);
|
||||
}
|
||||
|
||||
const isEmpty = removeEmpty.value ? isCanvasEmpty(spriteCtx, width, height) : false;
|
||||
|
||||
if (!removeEmpty.value || !isEmpty) {
|
||||
sprites.push({
|
||||
url: spriteCanvas.toDataURL('image/png'),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Yield control periodically for large batches
|
||||
if (i > 0 && i % 100 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
previewSprites.value = sprites;
|
||||
}
|
||||
|
||||
// Remove background color and make it transparent
|
||||
function removeBackgroundFromSprite(ctx: CanvasRenderingContext2D, width: number, height: number, backgroundColor: [number, number, number, number], sensitivity: number): void {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
const [bgR, bgG, bgB, bgA] = backgroundColor;
|
||||
const colorTolerance = Math.round(50 - sensitivity * 0.45); // Same as worker
|
||||
const alphaTolerance = Math.round(40 - sensitivity * 0.35);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
// Handle fully transparent pixels
|
||||
if (a < 10) {
|
||||
data[i + 3] = 0; // Make fully transparent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate color difference using Euclidean distance
|
||||
const rDiff = r - bgR;
|
||||
const gDiff = g - bgG;
|
||||
const bDiff = b - bgB;
|
||||
const aDiff = a - bgA;
|
||||
|
||||
const colorDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
|
||||
const alphaDistance = Math.abs(aDiff);
|
||||
|
||||
// If pixel matches background color, make it transparent
|
||||
if (colorDistance <= colorTolerance && alphaDistance <= alphaTolerance) {
|
||||
data[i + 3] = 0; // Set alpha to 0 (transparent)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
// Check if a canvas is empty (all transparent or same color)
|
||||
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Check if all pixels are transparent
|
||||
let allTransparent = true;
|
||||
let allSameColor = true;
|
||||
|
||||
// Reference values from first pixel
|
||||
const firstR = data[0];
|
||||
const firstG = data[1];
|
||||
const firstB = data[2];
|
||||
const firstA = data[3];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
|
||||
// Check transparency
|
||||
if (alpha > 10) {
|
||||
// Allow some tolerance for compression artifacts
|
||||
allTransparent = false;
|
||||
}
|
||||
|
||||
// Check if all pixels are the same color
|
||||
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
|
||||
allSameColor = false;
|
||||
}
|
||||
|
||||
// Early exit if we've determined it's not empty
|
||||
if (!allTransparent && !allSameColor) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allTransparent || allSameColor;
|
||||
}
|
||||
|
||||
// Convert preview sprites to actual files
|
||||
async function createSpriteFiles(): Promise<SpriteFile[]> {
|
||||
const spriteFiles: SpriteFile[] = [];
|
||||
|
||||
for (let i = 0; i < previewSprites.value.length; i++) {
|
||||
const sprite = previewSprites.value[i];
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(sprite.url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create file from blob
|
||||
const fileName = `sprite_${i + 1}.png`;
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
// Create sprite file with position information
|
||||
spriteFiles.push({
|
||||
file,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
});
|
||||
}
|
||||
|
||||
return spriteFiles;
|
||||
}
|
||||
|
||||
// Actions
|
||||
function cancel() {
|
||||
emit('close');
|
||||
@@ -850,8 +201,24 @@
|
||||
isProcessing.value = true;
|
||||
|
||||
try {
|
||||
const files = await createSpriteFiles();
|
||||
emit('split', files);
|
||||
const spriteFiles: SpriteFile[] = [];
|
||||
|
||||
for (let i = 0; i < previewSprites.value.length; i++) {
|
||||
const sprite = previewSprites.value[i];
|
||||
const response = await fetch(sprite.url);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `sprite_${i + 1}.png`, { type: 'image/png' });
|
||||
|
||||
spriteFiles.push({
|
||||
file,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
});
|
||||
}
|
||||
|
||||
emit('split', spriteFiles);
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error('Error creating sprite files:', error);
|
||||
@@ -860,19 +227,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Add these watchers to automatically update preview
|
||||
watch([rows, columns, removeEmpty, detectionMethod, sensitivity], () => {
|
||||
if (imageElement.value) {
|
||||
generatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up worker and cache on component unmount
|
||||
onUnmounted(() => {
|
||||
if (irregularWorker.value) {
|
||||
irregularWorker.value.terminate();
|
||||
irregularWorker.value = null;
|
||||
}
|
||||
detectionCache.clear();
|
||||
splitter.cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
316
src/composables/useSpritesheetSplitter.ts
Normal file
316
src/composables/useSpritesheetSplitter.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
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<SpritePreview[]> = ref([]);
|
||||
const worker: Ref<Worker | null> = ref(null);
|
||||
|
||||
/**
|
||||
* Split image into grid cells
|
||||
*/
|
||||
async function splitByGrid(img: HTMLImageElement, options: GridSplitOptions): Promise<SpritePreview[]> {
|
||||
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<SpritePreview[]> {
|
||||
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<SpritePreview[]>(resolve => {
|
||||
const w = worker.value!;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
w.removeEventListener('message', handleMessage);
|
||||
console.warn('Worker timeout');
|
||||
resolve([]);
|
||||
}, 30000);
|
||||
|
||||
const handleMessage = async (e: MessageEvent<SpriteWorkerResponse>) => {
|
||||
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<SpritePreview[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
54
src/types/spritesheet.ts
Normal file
54
src/types/spritesheet.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Spritesheet splitting types
|
||||
*/
|
||||
|
||||
/** A detected sprite region with bounds and position */
|
||||
export interface SpriteRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelCount?: number;
|
||||
}
|
||||
|
||||
/** A sprite preview with data URL for display */
|
||||
export interface SpritePreview {
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Detection mode for sprite splitting */
|
||||
export type DetectionMode = 'grid' | 'auto';
|
||||
|
||||
/** Options for grid-based splitting */
|
||||
export interface GridSplitOptions {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
preserveCellSize: boolean;
|
||||
removeEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Options for auto-detection splitting */
|
||||
export interface AutoDetectOptions {
|
||||
sensitivity: number;
|
||||
removeEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Worker message for irregular sprite detection */
|
||||
export interface SpriteWorkerMessage {
|
||||
type: 'detectIrregularSprites';
|
||||
imageData: ImageData;
|
||||
sensitivity: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
/** Worker response with detected sprites */
|
||||
export interface SpriteWorkerResponse {
|
||||
type: 'spritesDetected';
|
||||
sprites: SpriteRegion[];
|
||||
backgroundColor: [number, number, number, number];
|
||||
}
|
||||
Reference in New Issue
Block a user