Abfahrtsanzeiger Display Basic Library
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.
 
 
 
libmonoformat/backup/app.js

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