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);