testgemini/utils/imageUtils.ts

358 lines
11 KiB
TypeScript

import JSZip from 'jszip';
import { GIFEncoder, quantize, applyPalette } from 'gifenc';
import UPNG from 'upng-js';
/**
* Native implementation to save a blob as a file
*/
const saveAs = (blob: Blob, filename: string) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
/**
* Converts a File object to a Base64 string.
*/
export const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
/**
* Helper to load an image from base64
*/
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
};
/**
* Finds the bounding box of visible content in a canvas context
*/
const getContentBounds = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let minX = width, minY = height, maxX = 0, maxY = 0;
let found = false;
// Threshold for "content".
// We treat anything not fully transparent AND not near-white as content.
const whiteThreshold = 240;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const alpha = data[i + 3];
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Check if pixel is visible
if (alpha > 20) {
// Check if it's NOT white (background)
if (r < whiteThreshold || g < whiteThreshold || b < whiteThreshold) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
found = true;
}
}
}
}
if (!found) return null;
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
};
/**
* Processes a list of raw grid cells:
* 1. Finds the specific character bounds inside each cell (removes inner whitespace).
* 2. Determines the max character size across all frames.
* 3. Creates new frames where the character is centered and maximized.
*/
const alignAndMaximizeFrames = (frames: HTMLCanvasElement[]): string[] => {
// 1. Get bounds for all frames (Crop to character)
const bounds = frames.map(frame => {
const ctx = frame.getContext('2d');
if (!ctx) return null;
return getContentBounds(ctx, frame.width, frame.height);
});
// 2. Find max dimensions to ensure uniform frame size
let maxContentW = 0;
let maxContentH = 0;
bounds.forEach(b => {
if (b) {
maxContentW = Math.max(maxContentW, b.w);
maxContentH = Math.max(maxContentH, b.h);
}
});
if (maxContentW === 0 || maxContentH === 0) return [];
// Add very minimal padding (2px) just to avoid edge clipping
const padding = 2;
const finalW = maxContentW + padding * 2;
const finalH = maxContentH + padding * 2;
const resultBase64: string[] = [];
frames.forEach((frame, i) => {
const bound = bounds[i];
const canvas = document.createElement('canvas');
canvas.width = finalW;
canvas.height = finalH;
const ctx = canvas.getContext('2d');
if (ctx && bound) {
// Draw content from source frame (using bound coordinates) to center of new canvas
const targetX = (finalW - bound.w) / 2;
const targetY = (finalH - bound.h) / 2;
ctx.drawImage(
frame,
bound.x, bound.y, bound.w, bound.h, // Source crop
targetX, targetY, bound.w, bound.h // Destination location
);
resultBase64.push(canvas.toDataURL('image/png'));
} else if (ctx) {
// Empty frame? Keep it transparent
resultBase64.push(canvas.toDataURL('image/png'));
}
});
return resultBase64;
};
/**
* Strict Fixed Grid Slicing.
* 1. Finds the global content box of the entire sprite sheet (trims outer margins).
* 2. Divides that box into strictly equal rows and columns.
* 3. Extracts cells.
* 4. Sends to alignAndMaximizeFrames for final polish.
*/
const performFixedGridSlicing = (canvas: HTMLCanvasElement, rows: number, cols: number, removeBg: boolean): string[] => {
const ctx = canvas.getContext('2d');
if (!ctx) return [];
// 1. Trim the sprite sheet first to remove outer whitespace
const globalBounds = getContentBounds(ctx, canvas.width, canvas.height);
let sourceX = 0, sourceY = 0;
let sourceW = canvas.width, sourceH = canvas.height;
// If we found content, strictly use that area as the "Grid Area"
if (globalBounds) {
sourceX = globalBounds.x;
sourceY = globalBounds.y;
sourceW = globalBounds.w;
sourceH = globalBounds.h;
}
// Calculate cell size based on the content area
const frameWidth = sourceW / cols;
const frameHeight = sourceH / rows;
const rawCanvases: HTMLCanvasElement[] = [];
// Safety Shave: How many pixels to clear from edges to avoid neighbor artifacts
// We keep this small shave to prevent single-pixel bleeding, but rely on Prompt for main separation.
const safetyShave = 4;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cellCanvas = document.createElement('canvas');
// Use ceil to avoid sub-pixel clipping issues
cellCanvas.width = Math.ceil(frameWidth);
cellCanvas.height = Math.ceil(frameHeight);
const cellCtx = cellCanvas.getContext('2d');
if (cellCtx) {
cellCtx.drawImage(
canvas,
sourceX + c * frameWidth, // Precision float coordinate
sourceY + r * frameHeight,
frameWidth,
frameHeight,
0,
0,
cellCanvas.width,
cellCanvas.height
);
// --- SAFETY CLEANING STEP ---
// Shave edges to prevent "feet/head" overlap from neighbors if the AI draws slightly too big
cellCtx.clearRect(0, 0, cellCanvas.width, safetyShave); // Top edge
cellCtx.clearRect(0, cellCanvas.height - safetyShave, cellCanvas.width, safetyShave); // Bottom edge
cellCtx.clearRect(0, 0, safetyShave, cellCanvas.height); // Left edge
cellCtx.clearRect(cellCanvas.width - safetyShave, 0, safetyShave, cellCanvas.height); // Right edge
// NOTE: We removed the specific corner clearing (50x40) for numbers as requested.
// We now rely on the AI Prompt to strictly forbid numbers.
if (removeBg) {
const imageData = cellCtx.getImageData(0, 0, cellCanvas.width, cellCanvas.height);
const data = imageData.data;
const threshold = 230;
for (let i = 0; i < data.length; i += 4) {
// Simple white removal
if (data[i] > threshold && data[i + 1] > threshold && data[i + 2] > threshold) {
data[i + 3] = 0;
}
}
cellCtx.putImageData(imageData, 0, 0);
}
rawCanvases.push(cellCanvas);
}
}
}
// Send raw grid cells to be cropped and centered
return alignAndMaximizeFrames(rawCanvases);
};
/**
* Main Slicing Function
*/
export const sliceSpriteSheet = (
spriteSheetBase64: string,
rows: number,
cols: number,
removeBg: boolean = false
): Promise<string[]> => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous"; // Good practice
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) { resolve([]); return; }
ctx.drawImage(img, 0, 0);
// Force Fixed Grid Slicing.
// Smart slicing causes "two pics in one frame" errors when rows are close.
// We trust the AI followed the grid prompt.
const fixedFrames = performFixedGridSlicing(canvas, rows, cols, removeBg);
resolve(fixedFrames);
};
img.onerror = () => resolve([]);
img.src = spriteSheetBase64;
});
};
/**
* Download frames as a ZIP file
*/
export const downloadZip = async (frames: string[], filename: string = 'animation') => {
const zip = new JSZip();
const folder = zip.folder("frames");
frames.forEach((frame, index) => {
const base64Data = frame.split(',')[1];
folder?.file(`frame_${index + 1}.png`, base64Data, { base64: true });
});
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `${filename}.zip`);
};
/**
* Generate and download GIF
*/
export const downloadGif = async (frames: string[], fps: number, filename: string = 'animation') => {
if (frames.length === 0) return;
const firstImg = await loadImage(frames[0]);
const width = firstImg.width;
const height = firstImg.height;
const gif = GIFEncoder();
for (const frame of frames) {
const img = await loadImage(frame);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, width, height).data;
const palette = quantize(data, 256);
const index = applyPalette(data, palette);
const delay = 1000 / fps;
gif.writeFrame(index, width, height, {
palette,
delay: delay,
transparent: true,
dispose: -1
});
}
gif.finish();
const blob = new Blob([gif.bytes()], { type: 'image/gif' });
saveAs(blob, `${filename}.gif`);
};
/**
* Generate and download APNG (Best Quality)
*/
export const downloadApng = async (frames: string[], fps: number, filename: string = 'animation') => {
if (frames.length === 0) return;
const buffers: ArrayBuffer[] = [];
let width = 0;
let height = 0;
for (const frame of frames) {
const frameImg = await loadImage(frame);
// Ensure all frames are the same size (they should be from alignAndMaximizeFrames)
// But if not, handle it gracefully by creating a new canvas of max dimensions
if (width === 0) {
width = frameImg.width;
height = frameImg.height;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
ctx.drawImage(frameImg, 0, 0, width, height);
const buffer = ctx.getImageData(0, 0, width, height).data.buffer;
buffers.push(buffer);
}
const delay = Math.round(1000 / fps);
const delays = new Array(buffers.length).fill(delay);
const apngBuffer = UPNG.encode(buffers, width, height, 0, delays);
const blob = new Blob([apngBuffer], { type: 'image/png' });
saveAs(blob, `${filename}.png`);
};