You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1756 lines
58 KiB
1756 lines
58 KiB
let DISPLAY_WIDTH = 120;
|
|
const DISPLAY_HEIGHT = 60;
|
|
const BRIGHTNESS_THRESHOLD = 127;
|
|
const BORDER_BLACK_THRESHOLD = 64;
|
|
const BORDER_WHITE_THRESHOLD = 191;
|
|
|
|
document.documentElement.style.setProperty('--col-count', DISPLAY_WIDTH);
|
|
|
|
const upload = document.getElementById('upload');
|
|
const dropzone = document.getElementById('dropzone');
|
|
const matrix = document.getElementById('matrix');
|
|
const exportBtn = document.getElementById('export');
|
|
const invertBtn = document.getElementById('invert');
|
|
const ditherBtn = document.getElementById('dither');
|
|
const drawBtn = document.getElementById('draw');
|
|
const eraseBtn = document.getElementById('erase');
|
|
const resetBtn = document.getElementById('reset');
|
|
const brushSizeSelect = document.getElementById('brushSize');
|
|
const animationDurationInput = document.getElementById('animationDuration');
|
|
const revertDurationBtn = document.getElementById('revertDuration');
|
|
const textInputArea = document.getElementById('textInputArea');
|
|
const modeRadios = document.querySelectorAll('input[name="mode"]');
|
|
|
|
let currentMode = "image";
|
|
let drawMode = false;
|
|
let eraseMode = false;
|
|
let brushSize = 1;
|
|
let currentImage = null;
|
|
let currentFile = null;
|
|
let invertImageColors = false;
|
|
let useDithering = false;
|
|
let hasManualEdits = false;
|
|
let isDurationManuallyLocked = false; // Track if user manually adjusted duration
|
|
|
|
// GIF-specific state
|
|
let isAnimatedGif = false;
|
|
let gifFrames = [];
|
|
let gifFrameEdits = [];
|
|
let currentFrameIndex = 0;
|
|
let gifReader = null;
|
|
let isPlaying = false;
|
|
let animationTimer = null;
|
|
|
|
// Animation creation state
|
|
let isAnimationCreationMode = false;
|
|
let animationSourceFiles = []; // Store original files for multi-image animation
|
|
|
|
// --- Helper functions ---
|
|
// Calculate default duration from frame count (0.2s per frame)
|
|
const DEFAULT_FRAME_DELAY = 0.2; // seconds per frame
|
|
|
|
function calculateDurationFromFrames(frameCount) {
|
|
if (frameCount === 0) return 3.0; // default for empty
|
|
return Math.round(frameCount * DEFAULT_FRAME_DELAY * 10) / 10; // round to 0.1s
|
|
}
|
|
|
|
function updateAnimationDuration() {
|
|
if (isDurationManuallyLocked) return; // Don't update if manually locked
|
|
|
|
const frameCount = gifFrames.length;
|
|
const newDuration = calculateDurationFromFrames(frameCount);
|
|
animationDurationInput.value = newDuration.toFixed(1);
|
|
}
|
|
|
|
// Handle drawing/erasing at specific grid coordinates with brush size
|
|
function toggleDotAtCoordinates(x, y) {
|
|
const radius = brushSize / 2;
|
|
|
|
// Calculate the bounding box for the brush
|
|
const minX = Math.floor(x - radius);
|
|
const maxX = Math.floor(x + radius);
|
|
const minY = Math.floor(y - radius);
|
|
const maxY = Math.floor(y + radius);
|
|
|
|
// Iterate through all dots in the bounding box
|
|
for (let cy = minY; cy <= maxY; cy++) {
|
|
for (let cx = minX; cx <= maxX; cx++) {
|
|
// Skip if outside display bounds
|
|
if (cx < 0 || cx >= DISPLAY_WIDTH || cy < 0 || cy >= DISPLAY_HEIGHT) continue;
|
|
|
|
// Calculate distance from center
|
|
const distance = Math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y));
|
|
|
|
// Only activate dots within the circular radius
|
|
if (distance <= radius) {
|
|
const dot = document.querySelector(`.dot[data-x="${cx}"][data-y="${cy}"]`);
|
|
if (!dot) continue;
|
|
|
|
if (drawMode && !dot.classList.contains('on')) {
|
|
dot.classList.add('on');
|
|
hasManualEdits = true;
|
|
trackDotEdit(cx, cy, 'add');
|
|
}
|
|
if (eraseMode && dot.classList.contains('on')) {
|
|
dot.classList.remove('on');
|
|
hasManualEdits = true;
|
|
trackDotEdit(cx, cy, 'remove');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate which dot is closest to the click position
|
|
function getDotCoordinatesFromEvent(e) {
|
|
const rect = matrix.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left;
|
|
const clickY = e.clientY - rect.top;
|
|
|
|
// Each dot is 10px + 1px gap = 11px total
|
|
const dotSize = 11;
|
|
|
|
// Calculate grid coordinates
|
|
const gridX = Math.floor(clickX / dotSize);
|
|
const gridY = Math.floor(clickY / dotSize);
|
|
|
|
return { x: gridX, y: gridY };
|
|
}
|
|
|
|
// Matrix-level event handlers for drawing
|
|
let isDrawing = false;
|
|
|
|
function handleMatrixMouseDown(e) {
|
|
if (!drawMode && !eraseMode) return;
|
|
|
|
isDrawing = true;
|
|
const coords = getDotCoordinatesFromEvent(e);
|
|
toggleDotAtCoordinates(coords.x, coords.y);
|
|
e.preventDefault();
|
|
}
|
|
|
|
function handleMatrixMouseMove(e) {
|
|
if (!isDrawing) return;
|
|
if (!drawMode && !eraseMode) return;
|
|
|
|
const coords = getDotCoordinatesFromEvent(e);
|
|
toggleDotAtCoordinates(coords.x, coords.y);
|
|
e.preventDefault();
|
|
}
|
|
|
|
function handleMatrixMouseUp(e) {
|
|
isDrawing = false;
|
|
}
|
|
|
|
function handleMatrixMouseLeave(e) {
|
|
isDrawing = false;
|
|
}
|
|
|
|
function addDotInteraction(dot) {
|
|
// Individual dot interactions are now handled at the matrix level
|
|
// This function is kept for compatibility but does nothing
|
|
}
|
|
|
|
function setButtonActive(button, active) {
|
|
button.classList.toggle('active', active);
|
|
}
|
|
|
|
function toggleDrawEraseButtons(activeDraw) {
|
|
setButtonActive(drawBtn, activeDraw);
|
|
setButtonActive(eraseBtn, !activeDraw);
|
|
drawMode = activeDraw;
|
|
eraseMode = !activeDraw;
|
|
}
|
|
|
|
// Helper function to track dot edits for GIF frames
|
|
function trackDotEdit(x, y, action) {
|
|
if (!isAnimatedGif) return;
|
|
|
|
const key = `${x},${y}`;
|
|
if (action === 'add') {
|
|
gifFrameEdits[currentFrameIndex].addedDots.add(key);
|
|
gifFrameEdits[currentFrameIndex].removedDots.delete(key);
|
|
} else if (action === 'remove') {
|
|
gifFrameEdits[currentFrameIndex].removedDots.add(key);
|
|
gifFrameEdits[currentFrameIndex].addedDots.delete(key);
|
|
}
|
|
}
|
|
|
|
// Helper function to create a frame object
|
|
function createFrameObject(sourceFile, delay = 20) {
|
|
return {
|
|
imageData: null,
|
|
delay: delay,
|
|
processed: false,
|
|
sourceFile: sourceFile
|
|
};
|
|
}
|
|
|
|
// Helper function to create a frame edits object
|
|
function createFrameEditsObject() {
|
|
return {
|
|
addedDots: new Set(),
|
|
removedDots: new Set()
|
|
};
|
|
}
|
|
|
|
// Helper function to apply a single dot edit to the DOM
|
|
function applyDotEditToDOM(key, action) {
|
|
const [x, y] = key.split(',').map(Number);
|
|
const dot = document.querySelector(`.dot[data-x="${x}"][data-y="${y}"]`);
|
|
|
|
if (!dot) return;
|
|
|
|
if (action === 'add' && !dot.classList.contains('on')) {
|
|
dot.classList.add('on');
|
|
} else if (action === 'remove' && dot.classList.contains('on')) {
|
|
dot.classList.remove('on');
|
|
}
|
|
}
|
|
|
|
function applyDithering(data, width, height) {
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = (y * width + x) * 4;
|
|
const oldGray = (data.data[i] + data.data[i+1] + data.data[i+2]) / 3;
|
|
const newGray = oldGray > BRIGHTNESS_THRESHOLD ? 255 : 0;
|
|
const error = oldGray - newGray;
|
|
|
|
const val = invertImageColors ? 255 - newGray : newGray;
|
|
data.data[i] = data.data[i+1] = data.data[i+2] = val;
|
|
|
|
// Distribute error to neighboring pixels
|
|
if (x + 1 < width) {
|
|
const ri = (y * width + (x + 1)) * 4;
|
|
data.data[ri] = data.data[ri] + error * 7/16;
|
|
data.data[ri+1] = data.data[ri+1] + error * 7/16;
|
|
data.data[ri+2] = data.data[ri+2] + error * 7/16;
|
|
}
|
|
if (y + 1 < height) {
|
|
if (x - 1 >= 0) {
|
|
const bli = ((y+1) * width + (x - 1)) * 4;
|
|
data.data[bli] = data.data[bli] + error * 3/16;
|
|
data.data[bli+1] = data.data[bli+1] + error * 3/16;
|
|
data.data[bli+2] = data.data[bli+2] + error * 3/16;
|
|
}
|
|
const bi = ((y+1) * width + x) * 4;
|
|
data.data[bi] = data.data[bi] + error * 5/16;
|
|
data.data[bi+1] = data.data[bi+1] + error * 5/16;
|
|
data.data[bi+2] = data.data[bi+2] + error * 5/16;
|
|
|
|
if (x + 1 < width) {
|
|
const bri = ((y+1) * width + (x + 1)) * 4;
|
|
data.data[bri] = data.data[bri] + error * 1/16;
|
|
data.data[bri+1] = data.data[bri+1] + error * 1/16;
|
|
data.data[bri+2] = data.data[bri+2] + error * 1/16;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function invertCurrentMatrix() {
|
|
const dots = document.querySelectorAll('.dot');
|
|
dots.forEach(dot => {
|
|
dot.classList.toggle('on');
|
|
});
|
|
}
|
|
|
|
async function generateJSONExport() {
|
|
if (currentMode !== "image") {
|
|
// Text mode not supported by this function
|
|
return null;
|
|
}
|
|
|
|
if (isAnimatedGif) {
|
|
// Export all GIF frames as array of arrays
|
|
const allFrames = [];
|
|
|
|
// First, ensure all frames are processed
|
|
for (let i = 0; i < gifFrames.length; i++) {
|
|
try {
|
|
// Use appropriate processing based on whether it's a GIF or multi-image animation
|
|
if (animationSourceFiles && animationSourceFiles.length > 0) {
|
|
// Multi-image animation
|
|
await processStaticImageFrame(i);
|
|
} else {
|
|
// GIF animation
|
|
await processGifFrameAsync(i);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error processing frame ${i}:`, err);
|
|
}
|
|
}
|
|
|
|
// Now extract pixel data from all frames
|
|
for (let i = 0; i < gifFrames.length; i++) {
|
|
const frame = gifFrames[i];
|
|
const edits = gifFrameEdits[i];
|
|
const onDots = [];
|
|
|
|
if (frame.imageData) {
|
|
const data = frame.imageData.data;
|
|
|
|
// Extract "on" pixels from base frame
|
|
for (let y = 0; y < DISPLAY_HEIGHT; y++) {
|
|
for (let x = 0; x < DISPLAY_WIDTH; x++) {
|
|
const idx = (y * DISPLAY_WIDTH + x) * 4;
|
|
const brightness = (data[idx] + data[idx+1] + data[idx+2]) / 3;
|
|
const isOn = brightness > BRIGHTNESS_THRESHOLD;
|
|
const key = `${x},${y}`;
|
|
|
|
// Apply manual edits
|
|
let finalState = isOn;
|
|
if (edits.addedDots.has(key)) finalState = true;
|
|
if (edits.removedDots.has(key)) finalState = false;
|
|
|
|
if (finalState) {
|
|
onDots.push([x, y]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
allFrames.push(onDots);
|
|
}
|
|
|
|
return JSON.stringify(allFrames);
|
|
} else {
|
|
// Static image export
|
|
const onDots = Array.from(document.querySelectorAll('.dot.on')).map(el => [Number(el.dataset.x), Number(el.dataset.y)]);
|
|
return JSON.stringify(onDots);
|
|
}
|
|
}
|
|
|
|
// Helper function to check if there's user content
|
|
function hasUserContent() {
|
|
// Check if there's a loaded file or manual edits or any dots turned on
|
|
if (currentFile) return true;
|
|
if (hasManualEdits) return true;
|
|
if (gifFrames.length > 0) return true;
|
|
const onDots = document.querySelectorAll('.dot.on');
|
|
return onDots.length > 0;
|
|
}
|
|
|
|
// --- Mode toggle ---
|
|
modeRadios.forEach(r => {
|
|
r.addEventListener('change', (e) => {
|
|
const newMode = r.value;
|
|
|
|
// Check if switching from Image to Text mode with content
|
|
if (currentMode === "image" && newMode === "text" && hasUserContent()) {
|
|
if (!confirm('Switching to Text mode will clear all current content. Continue?')) {
|
|
// Revert the radio button selection
|
|
document.querySelector('input[name="mode"][value="image"]').checked = true;
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
stopAnimation();
|
|
currentMode = newMode;
|
|
drawMode = false;
|
|
eraseMode = false;
|
|
hasManualEdits = false;
|
|
setButtonActive(drawBtn, false);
|
|
setButtonActive(eraseBtn, false);
|
|
|
|
// Reset GIF state when switching modes
|
|
isAnimatedGif = false;
|
|
gifReader = null;
|
|
gifFrames = [];
|
|
gifFrameEdits = [];
|
|
animationSourceFiles = [];
|
|
currentFrameIndex = 0;
|
|
updateFrameControls();
|
|
|
|
if (currentMode === "image") {
|
|
dropzone.style.display = "block";
|
|
textInputArea.style.display = "none";
|
|
ditherBtn.style.display = "inline-block";
|
|
drawBtn.style.display = "inline-block";
|
|
eraseBtn.style.display = "inline-block";
|
|
brushSizeSelect.parentElement.style.display = "inline-block";
|
|
animationCreationCheckbox.disabled = false;
|
|
animationCreationCheckbox.parentElement.style.opacity = "1";
|
|
animationDurationInput.parentElement.style.display = "inline-block";
|
|
// Show revert button only if duration is locked
|
|
revertDurationBtn.style.display = isDurationManuallyLocked ? "inline-block" : "none";
|
|
} else {
|
|
dropzone.style.display = "none";
|
|
textInputArea.style.display = "block";
|
|
ditherBtn.style.display = "none";
|
|
drawBtn.style.display = "none";
|
|
eraseBtn.style.display = "none";
|
|
brushSizeSelect.parentElement.style.display = "none";
|
|
animationCreationCheckbox.disabled = true;
|
|
animationCreationCheckbox.parentElement.style.opacity = "0.5";
|
|
animationDurationInput.parentElement.style.display = "none";
|
|
revertDurationBtn.style.display = "none"; // Always hidden in text mode
|
|
currentFile = null; // Clear file reference in text mode
|
|
emptyCanvas();
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Animation Creation checkbox ---
|
|
const animationCreationCheckbox = document.getElementById('animationCreation');
|
|
|
|
// Initialize controls visibility for default mode (image)
|
|
drawBtn.style.display = "inline-block";
|
|
eraseBtn.style.display = "inline-block";
|
|
brushSizeSelect.parentElement.style.display = "inline-block";
|
|
animationCreationCheckbox.disabled = false;
|
|
animationCreationCheckbox.parentElement.style.opacity = "1";
|
|
animationDurationInput.parentElement.style.display = "inline-block";
|
|
revertDurationBtn.style.display = "none"; // Hidden by default (auto-update mode)
|
|
|
|
// Helper function to toggle animation creation mode
|
|
function setAnimationCreationMode(enabled) {
|
|
isAnimationCreationMode = enabled;
|
|
animationCreationCheckbox.checked = enabled;
|
|
|
|
if (enabled) {
|
|
upload.setAttribute('multiple', 'multiple');
|
|
} else {
|
|
upload.removeAttribute('multiple');
|
|
}
|
|
|
|
// Show/hide revert button based on animation mode and lock state
|
|
if (enabled && isDurationManuallyLocked) {
|
|
revertDurationBtn.style.display = "inline-block";
|
|
} else {
|
|
revertDurationBtn.style.display = "none";
|
|
}
|
|
|
|
updateFrameControls();
|
|
}
|
|
|
|
animationCreationCheckbox.addEventListener('change', () => {
|
|
setAnimationCreationMode(animationCreationCheckbox.checked);
|
|
});
|
|
|
|
function emptyCanvas() {
|
|
matrix.innerHTML = '';
|
|
matrix.style.setProperty('--col-count', DISPLAY_WIDTH);
|
|
for (let y = 0; y < DISPLAY_HEIGHT; y++) {
|
|
for (let x = 0; x < DISPLAY_WIDTH; x++) {
|
|
const dot = document.createElement('div');
|
|
dot.classList.add('dot');
|
|
dot.dataset.x = x;
|
|
dot.dataset.y = y;
|
|
addDotInteraction(dot);
|
|
matrix.appendChild(dot);
|
|
}
|
|
}
|
|
updateFrameControls();
|
|
}
|
|
|
|
async function loadImage(file) {
|
|
// Check if file is a GIF
|
|
if (await isGifFile(file)) {
|
|
await loadGif(file);
|
|
} else {
|
|
// Existing static image logic
|
|
currentFile = file;
|
|
hasManualEdits = false;
|
|
isAnimatedGif = false;
|
|
gifReader = null;
|
|
gifFrames = [];
|
|
gifFrameEdits = [];
|
|
animationSourceFiles = [];
|
|
updateFrameControls();
|
|
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
currentImage = img;
|
|
processImageToBW(img, (bwCanvas) => {
|
|
const ctx = bwCanvas.getContext('2d');
|
|
const imgData = ctx.getImageData(0, 0, bwCanvas.width, bwCanvas.height);
|
|
displayMatrix(imgData);
|
|
});
|
|
};
|
|
img.src = URL.createObjectURL(file);
|
|
}
|
|
}
|
|
|
|
async function loadMultipleImages(files) {
|
|
currentFile = null;
|
|
gifReader = null;
|
|
|
|
// Expand GIF files into individual frames
|
|
const expandedFiles = [];
|
|
for (const file of files) {
|
|
if (await isGifFile(file)) {
|
|
// Extract all frames from GIF
|
|
const frameBlobs = await extractGifFramesAsBlobs(file);
|
|
expandedFiles.push(...frameBlobs);
|
|
} else {
|
|
// Add non-GIF files as-is
|
|
expandedFiles.push(file);
|
|
}
|
|
}
|
|
|
|
// New files to add (keep in upload order, don't sort)
|
|
const newFiles = expandedFiles;
|
|
|
|
// Check if we're inserting into existing animation or starting fresh
|
|
const isInserting = animationSourceFiles.length > 0;
|
|
|
|
if (isInserting) {
|
|
// Insert new frames after current frame
|
|
const insertPosition = currentFrameIndex + 1;
|
|
|
|
// Split existing arrays at insertion point
|
|
const beforeFrames = animationSourceFiles.slice(0, insertPosition);
|
|
const afterFrames = animationSourceFiles.slice(insertPosition);
|
|
const beforeGifFrames = gifFrames.slice(0, insertPosition);
|
|
const afterGifFrames = gifFrames.slice(insertPosition);
|
|
const beforeEdits = gifFrameEdits.slice(0, insertPosition);
|
|
const afterEdits = gifFrameEdits.slice(insertPosition);
|
|
|
|
// Rebuild source files array with insertion
|
|
animationSourceFiles = [...beforeFrames, ...newFiles, ...afterFrames];
|
|
|
|
// Initialize new frame objects
|
|
const newGifFrames = [];
|
|
const newGifFrameEdits = [];
|
|
for (let i = 0; i < newFiles.length; i++) {
|
|
newGifFrames.push(createFrameObject(newFiles[i]));
|
|
newGifFrameEdits.push(createFrameEditsObject());
|
|
}
|
|
|
|
// Rebuild frame arrays with insertion
|
|
gifFrames = [...beforeGifFrames, ...newGifFrames, ...afterGifFrames];
|
|
gifFrameEdits = [...beforeEdits, ...newGifFrameEdits, ...afterEdits];
|
|
|
|
// Move to first newly inserted frame
|
|
currentFrameIndex = insertPosition;
|
|
|
|
} else {
|
|
// Starting fresh - no existing animation
|
|
hasManualEdits = false;
|
|
animationSourceFiles = newFiles;
|
|
|
|
// Initialize frame storage
|
|
const numFrames = newFiles.length;
|
|
gifFrames = new Array(numFrames);
|
|
gifFrameEdits = new Array(numFrames);
|
|
|
|
for (let i = 0; i < numFrames; i++) {
|
|
gifFrames[i] = createFrameObject(newFiles[i]);
|
|
gifFrameEdits[i] = createFrameEditsObject();
|
|
}
|
|
|
|
currentFrameIndex = 0;
|
|
}
|
|
|
|
// Set animation state
|
|
isAnimatedGif = gifFrames.length > 1;
|
|
|
|
// Process and display current frame
|
|
await processStaticImageFrame(currentFrameIndex);
|
|
|
|
// Update UI
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
}
|
|
|
|
async function processStaticImageFrame(frameIndex) {
|
|
if (frameIndex >= animationSourceFiles.length) return;
|
|
if (!gifFrames[frameIndex]) return; // Safety check for undefined frame
|
|
|
|
const file = animationSourceFiles[frameIndex];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
processImageToBW(img, (bwCanvas) => {
|
|
const ctx = bwCanvas.getContext('2d');
|
|
const processedData = ctx.getImageData(0, 0, bwCanvas.width, bwCanvas.height);
|
|
|
|
// Safety check - frame might have been cleared during async operation
|
|
if (!gifFrames[frameIndex]) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// Store processed frame
|
|
gifFrames[frameIndex].imageData = processedData;
|
|
gifFrames[frameIndex].processed = true;
|
|
|
|
// Display frame if it's the current one
|
|
if (frameIndex === currentFrameIndex) {
|
|
displayGifFrame(frameIndex);
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
};
|
|
img.onerror = () => reject('Failed to load image');
|
|
img.src = URL.createObjectURL(file);
|
|
});
|
|
}
|
|
|
|
async function isGifFile(file) {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
// GIF89a magic: 0x47 0x49 0x46 0x38 0x39 0x61
|
|
// GIF87a magic: 0x47 0x49 0x46 0x38 0x37 0x61
|
|
return bytes.length >= 6 &&
|
|
bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 &&
|
|
bytes[3] === 0x38 && (bytes[4] === 0x39 || bytes[4] === 0x37) &&
|
|
bytes[5] === 0x61;
|
|
}
|
|
|
|
async function loadGif(file) {
|
|
currentFile = file;
|
|
hasManualEdits = false;
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
|
|
try {
|
|
gifReader = new GifReader(bytes);
|
|
const numFrames = gifReader.numFrames();
|
|
|
|
// Initialize frame storage
|
|
gifFrames = new Array(numFrames);
|
|
gifFrameEdits = new Array(numFrames);
|
|
|
|
for (let i = 0; i < numFrames; i++) {
|
|
const frameInfo = gifReader.frameInfo(i);
|
|
gifFrames[i] = {
|
|
imageData: null,
|
|
delay: frameInfo.delay,
|
|
processed: false
|
|
};
|
|
gifFrameEdits[i] = {
|
|
addedDots: new Set(),
|
|
removedDots: new Set()
|
|
};
|
|
}
|
|
|
|
isAnimatedGif = numFrames > 1;
|
|
currentFrameIndex = 0;
|
|
|
|
// Auto-switch to animation creation mode for animated GIFs
|
|
if (isAnimatedGif && !isAnimationCreationMode) {
|
|
setAnimationCreationMode(true);
|
|
}
|
|
|
|
// If in animation creation mode, extract frames and use multi-image approach
|
|
if (isAnimationCreationMode && isAnimatedGif) {
|
|
const frameBlobs = await extractGifFramesAsBlobs(file);
|
|
await loadMultipleImages(frameBlobs);
|
|
return; // Exit early, loadMultipleImages handles the rest
|
|
}
|
|
|
|
// Process and display first frame (for non-animation-creation mode)
|
|
processAndDisplayGifFrame(0);
|
|
|
|
// Update UI
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
|
|
} catch (err) {
|
|
alert('Error loading GIF: ' + err.message);
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
// Helper function to extract all frames from a GIF as Blob objects
|
|
async function extractGifFramesAsBlobs(file) {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
|
|
try {
|
|
const reader = new GifReader(bytes);
|
|
const numFrames = reader.numFrames();
|
|
const frameBlobs = [];
|
|
|
|
for (let i = 0; i < numFrames; i++) {
|
|
// Decode frame from GIF to RGBA
|
|
const width = reader.width;
|
|
const height = reader.height;
|
|
const rgbaData = new Uint8Array(width * height * 4);
|
|
reader.decodeAndBlitFrameRGBA(i, rgbaData);
|
|
|
|
// Create canvas with decoded frame
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
const imageData = ctx.createImageData(width, height);
|
|
imageData.data.set(rgbaData);
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
// Convert canvas to Blob
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
|
|
// Create a synthetic file name
|
|
const originalName = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
|
Object.defineProperty(blob, 'name', {
|
|
value: `${originalName}_frame${String(i + 1).padStart(3, '0')}.png`,
|
|
writable: false
|
|
});
|
|
|
|
frameBlobs.push(blob);
|
|
}
|
|
|
|
return frameBlobs;
|
|
} catch (err) {
|
|
console.error('Error extracting GIF frames:', err);
|
|
return [file]; // Fallback to original file if extraction fails
|
|
}
|
|
}
|
|
|
|
function processGifFrameAsync(frameIndex) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!gifReader || frameIndex >= gifFrames.length || !gifFrames[frameIndex]) {
|
|
reject('Invalid frame index');
|
|
return;
|
|
}
|
|
|
|
// Check if frame already processed
|
|
if (gifFrames[frameIndex].processed) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// Decode frame from GIF to RGBA
|
|
const width = gifReader.width;
|
|
const height = gifReader.height;
|
|
const rgbaData = new Uint8Array(width * height * 4);
|
|
gifReader.decodeAndBlitFrameRGBA(frameIndex, rgbaData);
|
|
|
|
// Create temporary canvas with decoded frame
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = width;
|
|
tempCanvas.height = height;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
const tempImageData = tempCtx.createImageData(width, height);
|
|
tempImageData.data.set(rgbaData);
|
|
tempCtx.putImageData(tempImageData, 0, 0);
|
|
|
|
// Create Image object for processing
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
processImageToBW(img, (bwCanvas) => {
|
|
const ctx = bwCanvas.getContext('2d');
|
|
const processedData = ctx.getImageData(0, 0, bwCanvas.width, bwCanvas.height);
|
|
|
|
// Safety check - frame might have been cleared during async operation
|
|
if (!gifFrames[frameIndex]) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// Store processed frame
|
|
gifFrames[frameIndex].imageData = processedData;
|
|
gifFrames[frameIndex].processed = true;
|
|
|
|
resolve();
|
|
});
|
|
};
|
|
img.onerror = () => reject('Failed to load frame');
|
|
img.src = tempCanvas.toDataURL();
|
|
});
|
|
}
|
|
|
|
function processAndDisplayGifFrame(frameIndex) {
|
|
if (frameIndex >= gifFrames.length || !gifFrames[frameIndex]) return;
|
|
|
|
// Check if frame already processed with current settings
|
|
if (gifFrames[frameIndex].processed) {
|
|
displayGifFrame(frameIndex);
|
|
return;
|
|
}
|
|
|
|
// For multi-image animations (not GIFs)
|
|
if (animationSourceFiles && animationSourceFiles.length > 0) {
|
|
processStaticImageFrame(frameIndex).then(() => {
|
|
displayGifFrame(frameIndex);
|
|
}).catch(err => {
|
|
console.error('Error processing static image frame:', err);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// For GIF frames
|
|
if (!gifReader) return;
|
|
|
|
// Use the async version but don't wait
|
|
processGifFrameAsync(frameIndex).then(() => {
|
|
displayGifFrame(frameIndex);
|
|
}).catch(err => {
|
|
console.error('Error processing GIF frame:', err);
|
|
});
|
|
}
|
|
|
|
function displayGifFrame(frameIndex) {
|
|
if (!gifFrames[frameIndex] || !gifFrames[frameIndex].imageData) return;
|
|
|
|
currentFrameIndex = frameIndex;
|
|
const imageData = gifFrames[frameIndex].imageData;
|
|
|
|
// Display base frame using existing function
|
|
displayMatrix(imageData);
|
|
|
|
// Apply manual edits for this frame
|
|
const edits = gifFrameEdits[frameIndex];
|
|
if (edits) {
|
|
edits.addedDots.forEach(key => applyDotEditToDOM(key, 'add'));
|
|
edits.removedDots.forEach(key => applyDotEditToDOM(key, 'remove'));
|
|
}
|
|
|
|
// Update frame counter UI
|
|
updateFrameCounter();
|
|
}
|
|
|
|
function navigateToFrame(direction) {
|
|
if (!isAnimatedGif || gifFrames.length === 0) return;
|
|
|
|
let newIndex = currentFrameIndex;
|
|
if (direction === 'next') {
|
|
newIndex = (currentFrameIndex + 1) % gifFrames.length;
|
|
} else if (direction === 'prev') {
|
|
newIndex = (currentFrameIndex - 1 + gifFrames.length) % gifFrames.length;
|
|
}
|
|
|
|
processAndDisplayGifFrame(newIndex);
|
|
}
|
|
|
|
async function deleteCurrentFrame() {
|
|
if (!isAnimatedGif || gifFrames.length <= 1) {
|
|
return;
|
|
}
|
|
|
|
// Only allow deletion for multi-image animations (not GIFs)
|
|
if (!animationSourceFiles || animationSourceFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
stopAnimation();
|
|
|
|
// Remove frame from all arrays
|
|
animationSourceFiles.splice(currentFrameIndex, 1);
|
|
gifFrames.splice(currentFrameIndex, 1);
|
|
gifFrameEdits.splice(currentFrameIndex, 1);
|
|
|
|
// Adjust current frame index
|
|
if (currentFrameIndex >= gifFrames.length) {
|
|
currentFrameIndex = gifFrames.length - 1;
|
|
}
|
|
|
|
// Update animation state
|
|
isAnimatedGif = gifFrames.length > 1;
|
|
|
|
// Display the new current frame
|
|
if (gifFrames.length > 0) {
|
|
if (animationSourceFiles.length > 0) {
|
|
await processStaticImageFrame(currentFrameIndex);
|
|
} else {
|
|
processAndDisplayGifFrame(currentFrameIndex);
|
|
}
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
} else {
|
|
// No frames left, reset to empty canvas
|
|
isAnimatedGif = false;
|
|
emptyCanvas();
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
}
|
|
}
|
|
|
|
// Helper function to capture current canvas state as a Blob
|
|
async function captureCurrentCanvasAsBlob(name = 'captured_frame') {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = DISPLAY_WIDTH;
|
|
canvas.height = DISPLAY_HEIGHT;
|
|
const ctx = canvas.getContext('2d');
|
|
const imageData = ctx.createImageData(DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
|
|
|
// Read current state from DOM
|
|
for (let y = 0; y < DISPLAY_HEIGHT; y++) {
|
|
for (let x = 0; x < DISPLAY_WIDTH; x++) {
|
|
const dot = document.querySelector(`.dot[data-x="${x}"][data-y="${y}"]`);
|
|
const isOn = dot && dot.classList.contains('on');
|
|
const idx = (y * DISPLAY_WIDTH + x) * 4;
|
|
const val = isOn ? 255 : 0;
|
|
imageData.data[idx] = val; // R
|
|
imageData.data[idx + 1] = val; // G
|
|
imageData.data[idx + 2] = val; // B
|
|
imageData.data[idx + 3] = 255; // A
|
|
}
|
|
}
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
Object.defineProperty(blob, 'name', {
|
|
value: `${name}_${Date.now()}.png`,
|
|
writable: false
|
|
});
|
|
|
|
return blob;
|
|
}
|
|
|
|
// Add an empty frame after the current one
|
|
async function addEmptyFrame() {
|
|
if (!isAnimationCreationMode) return;
|
|
|
|
stopAnimation();
|
|
|
|
// If no frames exist, first capture the current canvas state
|
|
if (gifFrames.length === 0) {
|
|
const currentBlob = await captureCurrentCanvasAsBlob('current_frame');
|
|
|
|
// Add current state as frame 0
|
|
animationSourceFiles.push(currentBlob);
|
|
gifFrames.push(createFrameObject(currentBlob));
|
|
gifFrameEdits.push(createFrameEditsObject());
|
|
|
|
// Set to frame 0
|
|
currentFrameIndex = 0;
|
|
|
|
// Process frame 0 (this preserves the current drawing)
|
|
await processStaticImageFrame(0);
|
|
}
|
|
|
|
// Now add the empty frame after the current frame
|
|
const insertPosition = currentFrameIndex + 1;
|
|
|
|
// Create a blank canvas as a Blob
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = DISPLAY_WIDTH;
|
|
canvas.height = DISPLAY_HEIGHT;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'black';
|
|
ctx.fillRect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
|
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
Object.defineProperty(blob, 'name', {
|
|
value: `empty_frame_${Date.now()}.png`,
|
|
writable: false
|
|
});
|
|
|
|
// Insert into arrays
|
|
animationSourceFiles.splice(insertPosition, 0, blob);
|
|
gifFrames.splice(insertPosition, 0, createFrameObject(blob));
|
|
gifFrameEdits.splice(insertPosition, 0, createFrameEditsObject());
|
|
|
|
// Update state and switch to new frame
|
|
isAnimatedGif = gifFrames.length > 1;
|
|
currentFrameIndex = insertPosition;
|
|
|
|
// Process and display the new frame
|
|
await processStaticImageFrame(currentFrameIndex);
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
}
|
|
|
|
// Duplicate the current frame
|
|
async function duplicateCurrentFrame() {
|
|
if (!isAnimationCreationMode) return;
|
|
|
|
stopAnimation();
|
|
|
|
// If no frames exist, first capture the current canvas state
|
|
if (gifFrames.length === 0) {
|
|
const currentBlob = await captureCurrentCanvasAsBlob('current_frame');
|
|
|
|
// Add current state as frame 0
|
|
animationSourceFiles.push(currentBlob);
|
|
gifFrames.push(createFrameObject(currentBlob));
|
|
gifFrameEdits.push(createFrameEditsObject());
|
|
|
|
// Set to frame 0
|
|
currentFrameIndex = 0;
|
|
|
|
// Process frame 0
|
|
await processStaticImageFrame(0);
|
|
}
|
|
|
|
// Now duplicate the current frame
|
|
const insertPosition = currentFrameIndex + 1;
|
|
|
|
// Get current frame data
|
|
const currentFrame = gifFrames[currentFrameIndex];
|
|
if (!currentFrame) return; // Safety check
|
|
if (!currentFrame.imageData) {
|
|
await processStaticImageFrame(currentFrameIndex);
|
|
}
|
|
|
|
// Create a canvas with the current frame's content (including edits)
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = DISPLAY_WIDTH;
|
|
canvas.height = DISPLAY_HEIGHT;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Draw the base image
|
|
ctx.putImageData(gifFrames[currentFrameIndex].imageData, 0, 0);
|
|
|
|
// Apply edits to the canvas
|
|
const edits = gifFrameEdits[currentFrameIndex];
|
|
const imageData = ctx.getImageData(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
|
|
|
edits.addedDots.forEach(key => {
|
|
const [x, y] = key.split(',').map(Number);
|
|
const idx = (y * DISPLAY_WIDTH + x) * 4;
|
|
imageData.data[idx] = imageData.data[idx+1] = imageData.data[idx+2] = 255;
|
|
});
|
|
|
|
edits.removedDots.forEach(key => {
|
|
const [x, y] = key.split(',').map(Number);
|
|
const idx = (y * DISPLAY_WIDTH + x) * 4;
|
|
imageData.data[idx] = imageData.data[idx+1] = imageData.data[idx+2] = 0;
|
|
});
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
// Convert to Blob
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
Object.defineProperty(blob, 'name', {
|
|
value: `duplicate_frame_${Date.now()}.png`,
|
|
writable: false
|
|
});
|
|
|
|
// Insert into arrays
|
|
animationSourceFiles.splice(insertPosition, 0, blob);
|
|
gifFrames.splice(insertPosition, 0, createFrameObject(blob));
|
|
gifFrameEdits.splice(insertPosition, 0, createFrameEditsObject());
|
|
|
|
// Update state and switch to new frame
|
|
isAnimatedGif = gifFrames.length > 1;
|
|
currentFrameIndex = insertPosition;
|
|
|
|
// Process and display the new frame
|
|
await processStaticImageFrame(currentFrameIndex);
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
}
|
|
|
|
function updateFrameControls() {
|
|
const frameControls = document.getElementById('frameControls');
|
|
const deleteBtn = document.getElementById('deleteFrame');
|
|
const addEmptyBtn = document.getElementById('addEmptyFrame');
|
|
const duplicateBtn = document.getElementById('duplicateFrame');
|
|
const prevBtn = document.getElementById('prevFrame');
|
|
const nextBtn = document.getElementById('nextFrame');
|
|
const playBtn = document.getElementById('playStop');
|
|
|
|
// Show controls if:
|
|
// 1. Multiple frames exist (GIF or multi-image animation), OR
|
|
// 2. Animation creation mode is active (even with 0 frames, so user can add first frame)
|
|
const shouldShowControls = (gifFrames.length > 1 && (isAnimatedGif || animationSourceFiles.length > 0)) ||
|
|
isAnimationCreationMode;
|
|
|
|
if (shouldShowControls) {
|
|
frameControls.style.display = 'flex';
|
|
|
|
// Show navigation buttons (prev, next, play) only when there are 2+ frames
|
|
if (gifFrames.length > 1) {
|
|
prevBtn.style.display = 'inline-block';
|
|
nextBtn.style.display = 'inline-block';
|
|
playBtn.style.display = 'inline-block';
|
|
} else {
|
|
prevBtn.style.display = 'none';
|
|
nextBtn.style.display = 'none';
|
|
playBtn.style.display = 'none';
|
|
}
|
|
|
|
// Show delete button when there are multiple frames and it's a multi-image animation
|
|
if (gifFrames.length > 1 && animationSourceFiles && animationSourceFiles.length > 0) {
|
|
deleteBtn.style.display = 'inline-block';
|
|
} else {
|
|
deleteBtn.style.display = 'none';
|
|
}
|
|
|
|
// Show add empty frame button when in animation creation mode (even with 0 frames)
|
|
if (isAnimationCreationMode) {
|
|
addEmptyBtn.style.display = 'inline-block';
|
|
} else {
|
|
addEmptyBtn.style.display = 'none';
|
|
}
|
|
|
|
// Show duplicate button when in animation creation mode (even with 0 frames)
|
|
if (isAnimationCreationMode) {
|
|
duplicateBtn.style.display = 'inline-block';
|
|
} else {
|
|
duplicateBtn.style.display = 'none';
|
|
}
|
|
} else {
|
|
frameControls.style.display = 'none';
|
|
deleteBtn.style.display = 'none';
|
|
addEmptyBtn.style.display = 'none';
|
|
duplicateBtn.style.display = 'none';
|
|
prevBtn.style.display = 'none';
|
|
nextBtn.style.display = 'none';
|
|
playBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function updateFrameCounter() {
|
|
const counter = document.getElementById('frameCounter');
|
|
const shouldShowCounter = (gifFrames.length > 1 && (isAnimatedGif || animationSourceFiles.length > 0)) ||
|
|
(isAnimationCreationMode && gifFrames.length >= 1);
|
|
|
|
if (shouldShowCounter) {
|
|
counter.textContent = `Frame ${currentFrameIndex + 1} of ${gifFrames.length}`;
|
|
} else {
|
|
counter.textContent = '';
|
|
}
|
|
}
|
|
|
|
function startAnimation() {
|
|
if (!isAnimatedGif || isPlaying) return;
|
|
|
|
isPlaying = true;
|
|
const playStopBtn = document.getElementById('playStop');
|
|
playStopBtn.textContent = '⏸ Stop';
|
|
setButtonActive(playStopBtn, true);
|
|
|
|
animationTimer = setInterval(() => {
|
|
navigateToFrame('next');
|
|
}, 200); // 0.2 seconds
|
|
}
|
|
|
|
function stopAnimation() {
|
|
if (!isPlaying) return;
|
|
|
|
isPlaying = false;
|
|
const playStopBtn = document.getElementById('playStop');
|
|
playStopBtn.textContent = '▶ Play';
|
|
setButtonActive(playStopBtn, false);
|
|
|
|
if (animationTimer) {
|
|
clearInterval(animationTimer);
|
|
animationTimer = null;
|
|
}
|
|
}
|
|
|
|
function reprocessAllGifFrames() {
|
|
if (!isAnimatedGif || !gifReader) return;
|
|
|
|
// Mark all frames as unprocessed
|
|
gifFrames.forEach(frame => {
|
|
frame.processed = false;
|
|
frame.imageData = null;
|
|
});
|
|
|
|
// Reprocess current frame
|
|
processAndDisplayGifFrame(currentFrameIndex);
|
|
}
|
|
|
|
function detectBorderColor(img) {
|
|
const tmpCheck = document.createElement('canvas');
|
|
tmpCheck.width = img.width;
|
|
tmpCheck.height = img.height;
|
|
const ctxCheck = tmpCheck.getContext('2d');
|
|
ctxCheck.drawImage(img, 0, 0);
|
|
const checkData = ctxCheck.getImageData(0, 0, img.width, img.height);
|
|
|
|
let blackCount = 0;
|
|
let whiteCount = 0;
|
|
let totalBorderPixels = 0;
|
|
|
|
// Sample border pixels (top, bottom, left, right edges)
|
|
const sampleBorder = (x, y) => {
|
|
const i = (y * img.width + x) * 4;
|
|
const avg = (checkData.data[i] + checkData.data[i+1] + checkData.data[i+2]) / 3;
|
|
if (avg < BORDER_BLACK_THRESHOLD) blackCount++;
|
|
else if (avg > BORDER_WHITE_THRESHOLD) whiteCount++;
|
|
totalBorderPixels++;
|
|
};
|
|
|
|
// Sample top and bottom edges
|
|
for (let x = 0; x < img.width; x++) {
|
|
sampleBorder(x, 0);
|
|
sampleBorder(x, img.height - 1);
|
|
}
|
|
// Sample left and right edges (excluding corners already sampled)
|
|
for (let y = 1; y < img.height - 1; y++) {
|
|
sampleBorder(0, y);
|
|
sampleBorder(img.width - 1, y);
|
|
}
|
|
|
|
// Determine background color based on border analysis
|
|
if (blackCount / totalBorderPixels > 0.5) {
|
|
return 'black';
|
|
} else if (whiteCount / totalBorderPixels > 0.5) {
|
|
return 'white';
|
|
}
|
|
return 'black'; // default
|
|
}
|
|
|
|
function processImageToBW(img, callback) {
|
|
const tmp = document.createElement('canvas');
|
|
tmp.width = DISPLAY_WIDTH;
|
|
tmp.height = DISPLAY_HEIGHT;
|
|
const ctx = tmp.getContext('2d');
|
|
|
|
// Calculate aspect ratios
|
|
const imgAspect = img.width / img.height;
|
|
const displayAspect = DISPLAY_WIDTH / DISPLAY_HEIGHT;
|
|
|
|
let drawWidth, drawHeight, offsetX, offsetY;
|
|
|
|
if (imgAspect > displayAspect) {
|
|
// Image is wider - fit by width, center vertically
|
|
drawWidth = DISPLAY_WIDTH;
|
|
drawHeight = DISPLAY_WIDTH / imgAspect;
|
|
offsetX = 0;
|
|
offsetY = (DISPLAY_HEIGHT - drawHeight) / 2;
|
|
} else {
|
|
// Image is taller - fit by height, center horizontally
|
|
drawHeight = DISPLAY_HEIGHT;
|
|
drawWidth = DISPLAY_HEIGHT * imgAspect;
|
|
offsetX = (DISPLAY_WIDTH - drawWidth) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
// Detect and use border color for background
|
|
const bgColor = detectBorderColor(img);
|
|
ctx.fillStyle = bgColor;
|
|
ctx.fillRect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
|
|
|
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
|
const data = ctx.getImageData(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
|
|
|
if (useDithering) {
|
|
applyDithering(data, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
|
} else {
|
|
// Simple threshold
|
|
for (let i = 0; i < data.data.length; i += 4) {
|
|
const avg = (data.data[i] + data.data[i+1] + data.data[i+2]) / 3;
|
|
const val = invertImageColors ? 255 - (avg > BRIGHTNESS_THRESHOLD ? 255 : 0) : (avg > BRIGHTNESS_THRESHOLD ? 255 : 0);
|
|
data.data[i] = data.data[i+1] = data.data[i+2] = val;
|
|
}
|
|
}
|
|
|
|
ctx.putImageData(data, 0, 0);
|
|
callback(tmp);
|
|
}
|
|
|
|
function displayMatrix(imageData) {
|
|
matrix.innerHTML = '';
|
|
matrix.style.setProperty('--col-count', DISPLAY_WIDTH);
|
|
const d = imageData.data;
|
|
for (let y = 0; y < DISPLAY_HEIGHT; y++) {
|
|
for (let x = 0; x < DISPLAY_WIDTH; x++) {
|
|
const idx = (y * DISPLAY_WIDTH + x) * 4;
|
|
const brightness = (d[idx] + d[idx+1] + d[idx+2]) / 3;
|
|
const isOn = brightness > BRIGHTNESS_THRESHOLD;
|
|
const dot = document.createElement('div');
|
|
dot.classList.add('dot');
|
|
if (isOn) dot.classList.add('on');
|
|
dot.dataset.x = x;
|
|
dot.dataset.y = y;
|
|
addDotInteraction(dot);
|
|
matrix.appendChild(dot);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderCustomTextToMatrix(text, font, charWidth = 8, charHeight = 12, spacing = 1, lineSpacing = 2) {
|
|
const lines = text.split("\n");
|
|
const widestLine = Math.max(...lines.map(line => line.length));
|
|
const contentWidth = widestLine * (charWidth + spacing);
|
|
|
|
// expand display width
|
|
DISPLAY_WIDTH = Math.max(60, contentWidth + 2);
|
|
document.documentElement.style.setProperty('--col-count', DISPLAY_WIDTH);
|
|
|
|
const c = document.createElement("canvas");
|
|
c.width = DISPLAY_WIDTH;
|
|
c.height = DISPLAY_HEIGHT;
|
|
const ctx = c.getContext("2d");
|
|
|
|
ctx.fillStyle = "black";
|
|
ctx.fillRect(0, 0, c.width, c.height);
|
|
ctx.fillStyle = "white";
|
|
let currentXWriteIndex = 0;
|
|
let startY = 1;
|
|
for (let l = 0; l < lines.length; l++) {
|
|
const line = lines[l];
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
let ch = line[i];
|
|
if(!font[ch]) {
|
|
ch = ch.toUpperCase();
|
|
}
|
|
const glyph = font[ch] || font[" "];
|
|
if (!glyph) continue;
|
|
for (let y = 0; y < glyph.length; y++) {
|
|
const row = glyph[y];
|
|
for (let x = 0; x < row.length; x++) {
|
|
if (row.charAt(x) === "#") {
|
|
ctx.fillRect(1 + currentXWriteIndex + x, startY + y, 1, 1);
|
|
}
|
|
}
|
|
|
|
}
|
|
currentXWriteIndex += glyph[0].length + spacing;
|
|
}
|
|
currentXWriteIndex = spacing;
|
|
startY += charHeight + lineSpacing;
|
|
}
|
|
|
|
return { imageData: ctx.getImageData(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT), contentWidth };
|
|
}
|
|
|
|
// Auto-update text preview with debouncing (1 second after last keystroke)
|
|
let textUpdateTimer = null;
|
|
|
|
function updateTextPreview() {
|
|
const text = document.getElementById("scrollText").value.trim();
|
|
if (!text) {
|
|
emptyCanvas();
|
|
document.getElementById('scrolls_output').value = '';
|
|
return;
|
|
}
|
|
|
|
currentFile = null; // Clear file reference for text content
|
|
hasManualEdits = false;
|
|
const { imageData, contentWidth } = renderCustomTextToMatrix(text, FONT_5x7, 5, 7, 1);
|
|
displayMatrix(imageData);
|
|
|
|
// also update scrolls_output for server
|
|
const speed = Number(document.getElementById('scrollSpeed').value);
|
|
const directionRight = document.getElementById('scrollRight').checked;
|
|
const wrap = document.getElementById('scrollWrap').checked;
|
|
const scrollObj = [{
|
|
x: 0, y: 0,
|
|
width: DISPLAY_WIDTH,
|
|
height: 8,
|
|
contentWidth,
|
|
speed, directionRight, wrap,
|
|
pixelsOn: []
|
|
}];
|
|
document.getElementById('scrolls_output').value = JSON.stringify(scrollObj);
|
|
}
|
|
|
|
document.getElementById("scrollText").addEventListener("input", () => {
|
|
// Clear previous timer
|
|
if (textUpdateTimer) {
|
|
clearTimeout(textUpdateTimer);
|
|
}
|
|
|
|
// Set new timer for 1 second
|
|
textUpdateTimer = setTimeout(() => {
|
|
updateTextPreview();
|
|
}, 1000);
|
|
});
|
|
|
|
upload.addEventListener('change', e => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
// Auto-switch to animation creation mode if multiple files selected
|
|
if (files.length > 1 && !isAnimationCreationMode) {
|
|
setAnimationCreationMode(true);
|
|
}
|
|
|
|
if (isAnimationCreationMode) {
|
|
// Animation creation mode - treat as animation (even single file)
|
|
loadMultipleImages(files);
|
|
} else {
|
|
// Single file - load normally
|
|
loadImage(files[0]);
|
|
}
|
|
});
|
|
dropzone.addEventListener('click', () => upload.click());
|
|
|
|
// Drag-and-drop handlers
|
|
dropzone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dropzone.style.borderColor = '#888';
|
|
});
|
|
|
|
dropzone.addEventListener('dragleave', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dropzone.style.borderColor = '#555';
|
|
});
|
|
|
|
dropzone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dropzone.style.borderColor = '#555';
|
|
|
|
const files = e.dataTransfer.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
// Filter to only image files
|
|
const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
|
|
if (imageFiles.length === 0) return;
|
|
|
|
// Auto-switch to animation creation mode if multiple files dropped
|
|
if (imageFiles.length > 1 && !isAnimationCreationMode) {
|
|
setAnimationCreationMode(true);
|
|
}
|
|
|
|
if (isAnimationCreationMode) {
|
|
// Animation creation mode - treat as animation (even single file)
|
|
loadMultipleImages(imageFiles);
|
|
} else {
|
|
// Single file - load normally
|
|
loadImage(imageFiles[0]);
|
|
}
|
|
});
|
|
|
|
// Matrix drawing event handlers
|
|
matrix.addEventListener('mousedown', handleMatrixMouseDown);
|
|
matrix.addEventListener('mousemove', handleMatrixMouseMove);
|
|
matrix.addEventListener('mouseup', handleMatrixMouseUp);
|
|
matrix.addEventListener('mouseleave', handleMatrixMouseLeave);
|
|
|
|
invertBtn.addEventListener('click', () => {
|
|
invertImageColors = !invertImageColors;
|
|
setButtonActive(invertBtn, invertImageColors);
|
|
|
|
if (isAnimatedGif) {
|
|
// For GIFs: reprocess all frames with new invert setting
|
|
if (hasManualEdits && !confirm('Toggling invert will reprocess all frames. Manual edits will be preserved. Continue?')) {
|
|
invertImageColors = !invertImageColors; // Revert
|
|
setButtonActive(invertBtn, invertImageColors);
|
|
return;
|
|
}
|
|
reprocessAllGifFrames();
|
|
} else {
|
|
// For static images: just invert current matrix (preserves edits)
|
|
invertCurrentMatrix();
|
|
}
|
|
});
|
|
|
|
ditherBtn.addEventListener('click', () => {
|
|
if (hasManualEdits && !confirm('Toggling dither will reload the image and your manual edits may be lost. Continue?')) {
|
|
return;
|
|
}
|
|
useDithering = !useDithering;
|
|
ditherBtn.textContent = useDithering ? 'Dither: ON' : 'Dither: OFF';
|
|
setButtonActive(ditherBtn, useDithering);
|
|
|
|
if (isAnimatedGif) {
|
|
// For multi-image animations or GIFs
|
|
if (animationSourceFiles && animationSourceFiles.length > 0) {
|
|
// Multi-image animation - reprocess all frames
|
|
gifFrames.forEach(frame => {
|
|
frame.processed = false;
|
|
frame.imageData = null;
|
|
});
|
|
processStaticImageFrame(currentFrameIndex);
|
|
} else {
|
|
// GIF animation - reprocess all frames
|
|
reprocessAllGifFrames();
|
|
}
|
|
} else if (currentFile) {
|
|
loadImage(currentFile);
|
|
hasManualEdits = false;
|
|
}
|
|
});
|
|
|
|
drawBtn.addEventListener('click', () => {
|
|
toggleDrawEraseButtons(true);
|
|
});
|
|
|
|
eraseBtn.addEventListener('click', () => {
|
|
toggleDrawEraseButtons(false);
|
|
});
|
|
|
|
brushSizeSelect.addEventListener('change', (e) => {
|
|
brushSize = parseInt(e.target.value, 10);
|
|
});
|
|
|
|
// Lock duration when manually changed
|
|
animationDurationInput.addEventListener('input', () => {
|
|
isDurationManuallyLocked = true;
|
|
revertDurationBtn.style.display = 'inline-block';
|
|
revertDurationBtn.style.fontWeight = 'bold';
|
|
revertDurationBtn.style.color = '#f60';
|
|
});
|
|
|
|
// Revert button to unlock and recalculate duration
|
|
revertDurationBtn.addEventListener('click', () => {
|
|
isDurationManuallyLocked = false;
|
|
revertDurationBtn.style.display = 'none';
|
|
revertDurationBtn.style.fontWeight = 'normal';
|
|
revertDurationBtn.style.color = '';
|
|
updateAnimationDuration();
|
|
});
|
|
|
|
resetBtn.addEventListener('click', () => {
|
|
stopAnimation();
|
|
drawMode = false;
|
|
eraseMode = false;
|
|
setButtonActive(drawBtn, false);
|
|
setButtonActive(eraseBtn, false);
|
|
currentFile = null; // Clear file reference on reset
|
|
hasManualEdits = false;
|
|
|
|
// Reset GIF state
|
|
isAnimatedGif = false;
|
|
gifReader = null;
|
|
gifFrames = [];
|
|
gifFrameEdits = [];
|
|
animationSourceFiles = [];
|
|
currentFrameIndex = 0;
|
|
|
|
// Reset duration lock and UI
|
|
isDurationManuallyLocked = false;
|
|
revertDurationBtn.style.display = 'none';
|
|
revertDurationBtn.style.fontWeight = 'normal';
|
|
revertDurationBtn.style.color = '';
|
|
|
|
updateFrameControls();
|
|
updateFrameCounter();
|
|
updateAnimationDuration();
|
|
|
|
emptyCanvas();
|
|
});
|
|
|
|
exportBtn.addEventListener('click', async () => {
|
|
if (currentMode === "image") {
|
|
const jsonExport = await generateJSONExport();
|
|
document.getElementById('array_output').value = jsonExport;
|
|
document.getElementById('scrolls_output').value = '';
|
|
} else {
|
|
const text = document.getElementById('scrollText').value.trim();
|
|
if (!text) return alert("Enter some text first!");
|
|
const speed = Number(document.getElementById('scrollSpeed').value);
|
|
const directionRight = document.getElementById('scrollRight').checked;
|
|
const wrap = document.getElementById('scrollWrap').checked;
|
|
|
|
const { contentWidth } = renderCustomTextToMatrix(text, FONT_5x7, 5, 7, 1);
|
|
const onDots = Array.from(document.querySelectorAll('.dot.on')).map(el => [Number(el.dataset.x), Number(el.dataset.y)]);
|
|
document.getElementById('array_output').value = JSON.stringify(onDots);
|
|
|
|
const scrollObj = [{
|
|
x: 0, y: 0,
|
|
width: DISPLAY_WIDTH,
|
|
height: 8,
|
|
contentWidth,
|
|
speed, directionRight, wrap,
|
|
pixelsOn: []
|
|
}];
|
|
|
|
document.getElementById('scrolls_output').value = JSON.stringify(scrollObj);
|
|
}
|
|
});
|
|
|
|
// Helper function to try frame reduction for binary export
|
|
// Calculate updateInterval from duration and frame count
|
|
// duration: total animation duration in seconds
|
|
// frameCount: number of frames
|
|
// returns: updateInterval in 10ms units (for binary format)
|
|
function calculateUpdateInterval(duration, frameCount) {
|
|
if (frameCount === 0) return 3; // default fallback
|
|
const frameDelay = duration / frameCount; // seconds per frame
|
|
const updateInterval = Math.round(frameDelay * 100); // convert to 10ms units
|
|
return Math.max(1, Math.min(255, updateInterval)); // clamp to 1-255 (1 byte range)
|
|
}
|
|
|
|
// Reduce frames to target count by keeping frames uniformly
|
|
// Maintains the same total animation duration
|
|
function reduceFramesToTarget(frames, targetFrameCount, duration) {
|
|
if (frames.length <= targetFrameCount) {
|
|
const updateInterval = calculateUpdateInterval(duration, frames.length);
|
|
return { frames, updateInterval, skipPattern: 1 };
|
|
}
|
|
|
|
// Calculate how many frames to skip between kept frames
|
|
const skipPattern = Math.floor(frames.length / targetFrameCount);
|
|
const reducedFrames = [];
|
|
|
|
// Keep every Nth frame
|
|
for (let i = 0; i < frames.length; i += skipPattern) {
|
|
reducedFrames.push(frames[i]);
|
|
if (reducedFrames.length >= targetFrameCount) break;
|
|
}
|
|
|
|
// Calculate new updateInterval to maintain same total duration
|
|
const updateInterval = calculateUpdateInterval(duration, reducedFrames.length);
|
|
|
|
return {
|
|
frames: reducedFrames,
|
|
updateInterval,
|
|
skipPattern
|
|
};
|
|
}
|
|
|
|
// Download Binary button - converts JSON export to binary format
|
|
document.getElementById('downloadBinary').addEventListener('click', async () => {
|
|
if (currentMode === "image") {
|
|
try {
|
|
const MAX_BINARY_SIZE = 16 * 1024; // 16KB flash limit
|
|
|
|
// Get JSON export using shared function
|
|
let jsonExport = await generateJSONExport();
|
|
|
|
// For single images, wrap in array for parseJSONFrames compatibility
|
|
if (!isAnimatedGif) {
|
|
const singleFrame = JSON.parse(jsonExport);
|
|
jsonExport = JSON.stringify([singleFrame]);
|
|
}
|
|
|
|
// Always use 120x60 for image mode (DISPLAY_WIDTH can change in text mode)
|
|
const imageWidth = 120;
|
|
const imageHeight = 60;
|
|
|
|
// Parse frames first to get actual count
|
|
let frames = JSON.parse(jsonExport);
|
|
const originalFrameCount = frames.length;
|
|
|
|
// Get animation duration from input field
|
|
const animationDuration = parseFloat(document.getElementById('animationDuration').value) || 3.0;
|
|
|
|
// Calculate updateInterval based on duration and frame count
|
|
const initialUpdateInterval = calculateUpdateInterval(animationDuration, originalFrameCount);
|
|
|
|
const binaryOptions = {
|
|
startX: 0,
|
|
startY: 0,
|
|
updateInterval: initialUpdateInterval,
|
|
width: imageWidth,
|
|
height: imageHeight,
|
|
maxFrames: originalFrameCount // Use actual frame count, not arbitrary limit
|
|
};
|
|
|
|
// Generate initial binary
|
|
let binaryData = jsonToBinary(jsonExport, binaryOptions);
|
|
const originalSize = binaryData.length;
|
|
const TARGET_FRAME_COUNT = 17;
|
|
|
|
// Check if we need to reduce frames due to size limit
|
|
if (binaryData.length > MAX_BINARY_SIZE && frames.length > 1) {
|
|
// Try reducing to target frame count (maintains duration)
|
|
const reduction = reduceFramesToTarget(frames, TARGET_FRAME_COUNT, animationDuration);
|
|
|
|
// Update options with new updateInterval
|
|
const reducedOptions = {
|
|
...binaryOptions,
|
|
maxFrames: reduction.frames.length,
|
|
updateInterval: reduction.updateInterval
|
|
};
|
|
|
|
// Generate binary with reduced frames
|
|
const reducedBinary = jsonToBinary(JSON.stringify(reduction.frames), reducedOptions);
|
|
|
|
if (reducedBinary.length <= MAX_BINARY_SIZE) {
|
|
// Success - frames fit within size limit
|
|
const droppedCount = originalFrameCount - reduction.frames.length;
|
|
const originalFrameDelay = (animationDuration / originalFrameCount * 1000).toFixed(1);
|
|
const newFrameDelay = (animationDuration / reduction.frames.length * 1000).toFixed(1);
|
|
alert(
|
|
`Binary size reduction applied:\n\n` +
|
|
`Original: ${originalSize} bytes (${originalFrameCount} frames)\n` +
|
|
`Reduced: ${reducedBinary.length} bytes (${reduction.frames.length} frames)\n` +
|
|
`Maximum: ${MAX_BINARY_SIZE} bytes\n\n` +
|
|
`Kept every ${reduction.skipPattern}${reduction.skipPattern === 1 ? 'st' : reduction.skipPattern === 2 ? 'nd' : reduction.skipPattern === 3 ? 'rd' : 'th'} frame (${droppedCount} frames removed)\n` +
|
|
`Duration maintained: ${animationDuration}s\n` +
|
|
`Frame delay adjusted: ${originalFrameDelay}ms → ${newFrameDelay}ms\n` +
|
|
`Update interval: ${binaryOptions.updateInterval} → ${reduction.updateInterval}`
|
|
);
|
|
frames = reduction.frames;
|
|
binaryData = reducedBinary;
|
|
binaryOptions.updateInterval = reduction.updateInterval;
|
|
binaryOptions.maxFrames = reduction.frames.length;
|
|
} else {
|
|
// Even with reduction to target, still too large
|
|
alert(
|
|
`Binary file is too large to flash!\n\n` +
|
|
`Size: ${reducedBinary.length} bytes\n` +
|
|
`Maximum: ${MAX_BINARY_SIZE} bytes\n\n` +
|
|
`Even after reducing to ${TARGET_FRAME_COUNT} frames, the file exceeds the limit.\n` +
|
|
`Try reducing image complexity.`
|
|
);
|
|
return;
|
|
}
|
|
} else if (binaryData.length > MAX_BINARY_SIZE && frames.length === 1) {
|
|
// Single frame too large
|
|
alert(
|
|
`Binary file is too large to flash!\n\n` +
|
|
`Size: ${binaryData.length} bytes\n` +
|
|
`Maximum: ${MAX_BINARY_SIZE} bytes\n\n` +
|
|
`This is a single frame. Try reducing image complexity.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Create blob and trigger download
|
|
const blob = new Blob([binaryData], { type: 'application/octet-stream' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'animation.bin';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
console.log(`Binary file created: ${binaryData.length} bytes (${frames.length} frames)`);
|
|
} catch (err) {
|
|
alert('Error creating binary file: ' + err.message);
|
|
console.error(err);
|
|
}
|
|
} else {
|
|
alert('Binary export is only available in Image Mode.');
|
|
}
|
|
});
|
|
|
|
// Frame navigation event listeners
|
|
document.getElementById('prevFrame').addEventListener('click', () => {
|
|
stopAnimation();
|
|
navigateToFrame('prev');
|
|
});
|
|
|
|
document.getElementById('playStop').addEventListener('click', () => {
|
|
if (isPlaying) {
|
|
stopAnimation();
|
|
} else {
|
|
startAnimation();
|
|
}
|
|
});
|
|
|
|
document.getElementById('nextFrame').addEventListener('click', () => {
|
|
stopAnimation();
|
|
navigateToFrame('next');
|
|
});
|
|
|
|
document.getElementById('deleteFrame').addEventListener('click', () => {
|
|
deleteCurrentFrame();
|
|
});
|
|
|
|
document.getElementById('addEmptyFrame').addEventListener('click', () => {
|
|
addEmptyFrame();
|
|
});
|
|
|
|
document.getElementById('duplicateFrame').addEventListener('click', () => {
|
|
duplicateCurrentFrame();
|
|
});
|
|
|
|
// Keyboard shortcuts for frame navigation
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!isAnimatedGif) return;
|
|
|
|
if (e.key === 'ArrowLeft' && !e.target.matches('input, textarea')) {
|
|
e.preventDefault();
|
|
stopAnimation();
|
|
navigateToFrame('prev');
|
|
} else if (e.key === 'ArrowRight' && !e.target.matches('input, textarea')) {
|
|
e.preventDefault();
|
|
stopAnimation();
|
|
navigateToFrame('next');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('load', emptyCanvas);
|
|
|