diff --git a/package.json b/package.json index 6fac4ff..35576a7 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "tiny-sprites": "src/dev/server.tsx" }, "scripts": { - "dev": "bun --hot src/dev/server.tsx", + "dev": "bun --hot src/dev/server.tsx ./assets", "test": "tsc --noEmit && bun test" }, "dependencies": { - "hono": "^4" + "hono": "^4.11.3" }, "devDependencies": { "bun-types": "latest" diff --git a/src/dev/analyze.ts b/src/dev/analyze.ts new file mode 100644 index 0000000..efc213b --- /dev/null +++ b/src/dev/analyze.ts @@ -0,0 +1,160 @@ +export type Crop = { + x: number + y: number + width: number + height: number +} + +export type AnalyzeResult = { + frames: number + crop: Crop +} + +export const analyzeSprite = (img: HTMLImageElement): AnalyzeResult => { + const { data, width, height } = getImageData(img) + + // Find columns with content (non-transparent pixels) + const colHasContent = new Array(width).fill(false) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + // pixel data is [R,G,B,A,R,G,B,A,...], so (y*width+x)*4+3 gets the alpha byte + const alpha = data[(y * width + x) * 4 + 3] + if (alpha > 0) { + colHasContent[x] = true + } + } + } + + const frames = detectFrames(colHasContent, width) + const crop = calculateCrop(img, frames) + + return { frames, crop } +} + +export const calculateCrop = (img: HTMLImageElement, frames: number): Crop => { + const { data, width, height } = getImageData(img) + const frameWidth = Math.floor(width / frames) + + // For each frame, find the bounding box, then union them all + let unionMinX = Infinity + let unionMinY = Infinity + let unionMaxX = -Infinity + let unionMaxY = -Infinity + + for (let f = 0; f < frames; f++) { + const frameStartX = f * frameWidth + const frameEndX = frameStartX + frameWidth + const bounds = getFrameBounds(data, width, height, frameStartX, frameEndX) + + if (bounds) { + // Convert to frame-local coordinates + const localMinX = bounds.minX - frameStartX + const localMaxX = bounds.maxX - frameStartX + + unionMinX = Math.min(unionMinX, localMinX) + unionMinY = Math.min(unionMinY, bounds.minY) + unionMaxX = Math.max(unionMaxX, localMaxX) + unionMaxY = Math.max(unionMaxY, bounds.maxY) + } + } + + // Handle case where no content found + if (unionMinX === Infinity) { + return { x: 0, y: 0, width: frameWidth, height } + } + + return { + x: unionMinX, + y: unionMinY, + width: unionMaxX - unionMinX + 1, + height: unionMaxY - unionMinY + 1, + } +} + +const getImageData = (img: HTMLImageElement) => { + const canvas = document.createElement("canvas") + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext("2d")! + ctx.drawImage(img, 0, 0) + return ctx.getImageData(0, 0, img.width, img.height) +} + +const detectFrames = (colHasContent: boolean[], width: number): number => { + // Find regions, requiring gaps of at least 10px to separate frames + const minGapWidth = 10 + const regions: { start: number; end: number }[] = [] + let inRegion = false + let regionStart = 0 + let gapStart = -1 + + for (let x = 0; x < width; x++) { + if (colHasContent[x]) { + if (!inRegion) { + inRegion = true + regionStart = x + } + gapStart = -1 + } else if (inRegion) { + if (gapStart === -1) { + gapStart = x + } + if (x - gapStart >= minGapWidth) { + regions.push({ start: regionStart, end: gapStart - 1 }) + inRegion = false + gapStart = -1 + } + } + } + if (inRegion) { + regions.push({ start: regionStart, end: gapStart === -1 ? width - 1 : gapStart - 1 }) + } + + if (regions.length === 0) { + throw new Error("No content found in spritesheet") + } + + // Check if regions are roughly evenly spaced + const avgWidth = width / regions.length + const tolerance = avgWidth * 0.4 + + for (let i = 0; i < regions.length; i++) { + const expectedCenter = avgWidth * i + avgWidth / 2 + const actualCenter = (regions[i].start + regions[i].end) / 2 + if (Math.abs(expectedCenter - actualCenter) > tolerance) { + throw new Error("Frames are not evenly spaced") + } + } + + return regions.length +} + +const getFrameBounds = ( + data: Uint8ClampedArray, + width: number, + height: number, + startX: number, + endX: number +): { minX: number; minY: number; maxX: number; maxY: number } | undefined => { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (let y = 0; y < height; y++) { + for (let x = startX; x < endX; x++) { + const alpha = data[(y * width + x) * 4 + 3] + if (alpha > 0) { + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + } + } + + if (minX === Infinity) return undefined + + return { minX, minY, maxX, maxY } +} diff --git a/src/dev/app.tsx b/src/dev/app.tsx index d9e2f92..de3e8e8 100644 --- a/src/dev/app.tsx +++ b/src/dev/app.tsx @@ -1,153 +1,415 @@ -import { useState, useEffect } from "hono/jsx" import { render } from "hono/jsx/dom" +import { analyzeSprite, calculateCrop, type Crop } from "./analyze" const STORAGE_KEY = "tiny-sprites-last-file" -const loadFromStorage = () => { - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) return JSON.parse(stored) - } catch {} +const state = { + spriteUrl: "", + fileName: "", + relativePath: "", // Path relative to assets dir for generated code + imageWidth: 0, + imageHeight: 0, + frames: 4, + frameDuration: 100, + scale: 1, + crop: undefined as Crop | undefined, + availableImages: [] as string[], } -const saveToStorage = (data: { dataUrl: string; width: number; height: number }) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) - } catch {} -} - -const App = () => { - const stored = loadFromStorage() - const [spriteUrl, setSpriteUrl] = useState(stored?.dataUrl || "") - const [imageWidth, setImageWidth] = useState(stored?.width || 0) - const [imageHeight, setImageHeight] = useState(stored?.height || 0) - const [frames, setFrames] = useState(4) - const [columns, setColumns] = useState() - const [frameDuration, setFrameDuration] = useState(100) - const [scale, setScale] = useState(1) - - const handleFileChange = (e: Event) => { - const file = (e.target as HTMLInputElement).files?.[0] - if (!file) return - - const reader = new FileReader() - reader.onload = () => { - const dataUrl = reader.result as string - const img = new Image() - img.onload = () => { - setImageWidth(img.width) - setImageHeight(img.height) - setSpriteUrl(dataUrl) - saveToStorage({ dataUrl, width: img.width, height: img.height }) - } - img.src = dataUrl - } - reader.readAsDataURL(file) +// Load from localStorage +try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const data = JSON.parse(stored) + state.spriteUrl = data.spriteUrl || "" + state.fileName = data.fileName || "" + state.relativePath = data.relativePath || "" + state.imageWidth = data.imageWidth || 0 + state.imageHeight = data.imageHeight || 0 + state.frames = data.frames || 4 + state.crop = data.crop } +} catch {} + +const saveToStorage = () => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + spriteUrl: state.spriteUrl, + fileName: state.fileName, + relativePath: state.relativePath, + imageWidth: state.imageWidth, + imageHeight: state.imageHeight, + frames: state.frames, + crop: state.crop, + }) + ) + } catch {} +} + +const loadImageFromPath = (path: string) => { + const url = `/assets/${encodeURIComponent(path).replace(/%2F/g, "/")}` + const img = new Image() + img.onload = () => { + state.spriteUrl = url + state.fileName = path.split("/").pop() || path + state.relativePath = path + state.imageWidth = img.width + state.imageHeight = img.height + + try { + const result = analyzeSprite(img) + state.frames = result.frames + state.crop = result.crop + $("frames").value = String(result.frames) + updateCropInputs() + $("error-message").style.display = "none" + } catch (e) { + state.crop = undefined + const msg = e instanceof Error ? e.message : "Unknown error" + $("error-message").textContent = `Auto-detect failed: ${msg}. Set frame count manually.` + $("error-message").style.display = "block" + } + + saveToStorage() + updatePreview() + renderImagePicker($("filter").value.toLowerCase()) + } + img.src = url +} + +const $ = (id: string) => document.getElementById(id) as T + +const updateCropInputs = () => { + $("crop-x").value = String(state.crop?.x ?? 0) + $("crop-y").value = String(state.crop?.y ?? 0) + $("crop-width").value = String(state.crop?.width ?? 0) + $("crop-height").value = String(state.crop?.height ?? 0) +} + +const updatePreview = () => { + const { spriteUrl, imageWidth, imageHeight, frames, frameDuration, scale, crop } = state + + const frameWidth = imageWidth > 0 ? Math.floor(imageWidth / frames) : 32 + const frameHeight = imageHeight > 0 ? imageHeight : 32 + + // Use crop dimensions if available, otherwise full frame + const displayWidth = crop ? crop.width : frameWidth + const displayHeight = crop ? crop.height : frameHeight + const offsetX = crop ? crop.x : 0 + const offsetY = crop ? crop.y : 0 - // Auto-calculate frame dimensions from image size and frame count - const isGrid = columns !== undefined - const cols = isGrid ? columns : frames - const rows = isGrid ? Math.ceil(frames / columns!) : 1 - const width = imageWidth > 0 ? Math.floor(imageWidth / cols) : 32 - const height = imageHeight > 0 ? Math.floor(imageHeight / rows) : 32 - const sheetWidth = cols * width - const sheetHeight = rows * height const totalDuration = frames * frameDuration - const keyframeId = "sprite-preview" - let keyframes: string + // Update info displays + $("frame-info").textContent = spriteUrl ? `Frame: ${frameWidth} x ${frameHeight} px` : "" + $("scale-label").textContent = `Scale (${scale}x)` - if (isGrid) { - const steps: string[] = [] - for (let i = 0; i < frames; i++) { - const col = i % columns! - const row = Math.floor(i / columns!) - const x = -col * width * scale - const y = -row * height * scale - const percent = (i / frames) * 100 - steps.push(`${percent}%{background-position:${x}px ${y}px}`) + // Update preview - show full frame with crop overlay + const preview = $("preview") + const previewStyle = $("preview-style") + const cropOverlay = $("crop-overlay") + + if (spriteUrl) { + const keyframeId = "sprite-preview" + const fullKeyframes = `@keyframes ${keyframeId}{from{background-position:0 0}to{background-position:-${imageWidth * scale}px 0}}` + previewStyle.textContent = fullKeyframes + preview.style.width = `${frameWidth * scale}px` + preview.style.height = `${frameHeight * scale}px` + preview.style.backgroundImage = `url('${spriteUrl}')` + preview.style.backgroundSize = `${imageWidth * scale}px ${imageHeight * scale}px` + preview.style.animation = `${keyframeId} ${totalDuration}ms steps(${frames}) infinite` + preview.style.display = "block" + preview.style.position = "relative" + + // Position crop overlay to show what's kept vs cropped + if (crop) { + cropOverlay.style.position = "absolute" + cropOverlay.style.top = `${offsetY * scale}px` + cropOverlay.style.left = `${offsetX * scale}px` + cropOverlay.style.width = `${displayWidth * scale}px` + cropOverlay.style.height = `${displayHeight * scale}px` + cropOverlay.style.boxShadow = `0 0 0 9999px rgba(0, 0, 0, 0.6)` + cropOverlay.style.display = "block" + } else { + cropOverlay.style.display = "none" } - keyframes = `@keyframes ${keyframeId}{${steps.join("")}}` + + $("preview-placeholder").style.display = "none" } else { - keyframes = `@keyframes ${keyframeId}{from{background-position:0 0}to{background-position:-${sheetWidth * scale}px 0}}` + preview.style.display = "none" + cropOverlay.style.display = "none" + $("preview-placeholder").style.display = "block" } - const previewStyle = { - width: `${width * scale}px`, - height: `${height * scale}px`, - backgroundColor: "transparent", - backgroundImage: `url('${spriteUrl}')`, - backgroundSize: `${sheetWidth * scale}px ${sheetHeight * scale}px`, - animation: `${keyframeId} ${totalDuration}ms steps(${frames}) infinite`, - } - - const columnsAttr = columns ? `\n columns={${columns}}` : "" - const code = `("code").value = `` - - const copyCode = () => navigator.clipboard.writeText(code) - - return ( -
-
- - {spriteUrl && ( -
- Spritesheet ({imageWidth} x {imageHeight}) - -
- )} - - - - - {spriteUrl && ( - Frame size: {width} x {height} px - )} -
-
-
- {spriteUrl ? ( - <> - -
- - ) : ( - Upload a spritesheet to preview - )} -
- + +
+
+) + render(, document.getElementById("app")!) + +// Initial update after render +setTimeout(() => { + $("frames").value = String(state.frames) + updateCropInputs() + updatePreview() + fetchImages() +}, 0) diff --git a/src/dev/index.html b/src/dev/index.html index 78cba86..1c20970 100644 --- a/src/dev/index.html +++ b/src/dev/index.html @@ -1,32 +1,33 @@ - - - - Tiny Sprites - - - - -
-

Tiny Sprites

-
-
- - + + + + Tiny Sprites + + + + +
+

Tiny Sprites

+
+
+ + diff --git a/src/dev/server.tsx b/src/dev/server.tsx index 6b29d81..f58873f 100644 --- a/src/dev/server.tsx +++ b/src/dev/server.tsx @@ -1,18 +1,78 @@ #!/usr/bin/env bun +import { Glob } from "bun" +import { resolve } from "path" import index from "./index.html" +const args = process.argv.slice(2) +const helpFlag = args.includes("--help") || args.includes("-h") +const assetsDirArg = args.find((arg) => !arg.startsWith("-")) + +if (helpFlag || !assetsDirArg) { + console.log(` +tiny-sprites dev server + +Usage: + tiny-sprites + bun src/dev/server.tsx + +Arguments: + assets-dir Directory containing sprite images (required) + +Options: + -h, --help Show this help message + +Example: + tiny-sprites ./assets + bun --hot src/dev/server.tsx ./assets +`) + process.exit(helpFlag ? 0 : 1) +} + +// Resolve to absolute path +const assetsDir = resolve(assetsDirArg) + +// Verify assets directory exists +try { + const glob = new Glob("*") + await glob.scan(assetsDir).next() +} catch { + console.error(`Error: Assets directory not found: ${assetsDir}`) + process.exit(1) +} + +console.log(`Assets directory: ${assetsDir}`) + +const getImageList = async (): Promise => { + const glob = new Glob("**/*.{png,jpg,jpeg,gif,webp}") + const images: string[] = [] + + for await (const path of glob.scan(assetsDir)) { + images.push(path) + } + + return images.sort() +} + Bun.serve({ port: 3000, routes: { "/": index, - "/public/*": async (req) => { - const path = new URL(req.url).pathname.replace("/public/", "") - const file = Bun.file(`./public/${path}`) + "/api/images": async () => { + const images = await getImageList() + return Response.json({ assetsDir, images }) + }, + }, + async fetch(req) { + const url = new URL(req.url) + if (url.pathname.startsWith("/assets/")) { + const path = decodeURIComponent(url.pathname.replace("/assets/", "")) + const file = Bun.file(`${assetsDir}/${path}`) if (await file.exists()) { return new Response(file) } return new Response("Not found", { status: 404 }) - }, + } + return new Response("Not found", { status: 404 }) }, development: true, }) diff --git a/src/sprite.test.tsx b/src/sprite.test.tsx index e5377a9..356eb3d 100644 --- a/src/sprite.test.tsx +++ b/src/sprite.test.tsx @@ -1,59 +1,57 @@ import { test, expect } from "bun:test" -import { Sprite } from "./sprite" +import { Sprite, SpriteStyles } from "./sprite" -test("Sprite renders div with correct dimensions", () => { - const html = ().toString() - expect(html).toContain('width:32px') - expect(html).toContain('height:32px') +const baseProps = { src: "/test.png", frames: 4, frameDuration: 100, sheetWidth: 128, sheetHeight: 32 } + +test("SpriteStyles renders global keyframe", () => { + const html = ().toString() + expect(html).toContain(" +) + export const Sprite = (props: SpriteProps) => { const { src, - width, - height, frames, frameDuration, - columns, + sheetWidth, + sheetHeight, + crop, + scale = 1, class: className, - style, + style = "", playing = true, } = props - const isGrid = columns !== undefined - const rows = isGrid ? Math.ceil(frames / columns!) : 1 - const cols = isGrid ? columns! : frames + const frameWidth = Math.floor(sheetWidth / frames) + const frameHeight = sheetHeight - const sheetWidth = cols * width - const sheetHeight = rows * height - const totalDuration = frames * frameDuration + const displayWidth = (crop ? crop.width : frameWidth) * scale + const displayHeight = (crop ? crop.height : frameHeight) * scale + const offsetX = crop ? crop.x : 0 + const offsetY = crop ? crop.y : 0 - const keyframeId = `sprite-${Bun.hash( - `${src}-${width}-${height}-${frames}-${frameDuration}-${columns}` - ).toString(36)}` + const scaledSheetWidth = sheetWidth * scale + const scaledSheetHeight = sheetHeight * scale + const duration = frames * frameDuration - const keyframes = isGrid - ? generateGridKeyframes(keyframeId, width, height, frames, columns!) - : generateStripKeyframes(keyframeId, sheetWidth) + const fromX = -offsetX * scale + const toX = (-sheetWidth - offsetX) * scale + const y = -offsetY * scale const divStyle = [ - `width:${width}px`, - `height:${height}px`, + `--x:${fromX}px`, + `--y:${y}px`, + `--ex:${toX}px`, + `width:${displayWidth}px`, + `height:${displayHeight}px`, `background-image:url('${src}')`, - `background-size:${sheetWidth}px ${sheetHeight}px`, - `animation:${keyframeId} ${totalDuration}ms steps(${frames}) infinite`, + `background-size:${scaledSheetWidth}px ${scaledSheetHeight}px`, + `animation:sprite ${duration}ms steps(${frames}) infinite`, playing ? "" : "animation-play-state:paused", style, ] .filter(Boolean) .join(";") - return ( - <> - -
- - ) -} - -const generateStripKeyframes = (id: string, sheetWidth: number): string => - `@keyframes ${id}{from{background-position:0 0}to{background-position:-${sheetWidth}px 0}}` - -const generateGridKeyframes = ( - id: string, - width: number, - height: number, - frames: number, - columns: number -): string => { - const steps: string[] = [] - for (let i = 0; i < frames; i++) { - const col = i % columns - const row = Math.floor(i / columns) - const x = -col * width - const y = -row * height - const percent = (i / frames) * 100 - steps.push(`${percent}%{background-position:${x}px ${y}px}`) - } - return `@keyframes ${id}{${steps.join("")}}` + return
}