adding advanced settings

This commit is contained in:
Dan 2026-01-09 20:20:18 +00:00
parent 5a004981a9
commit b27f9cfb39
3 changed files with 423 additions and 5 deletions

View file

@ -1,5 +1,7 @@
import { ButtonEffect } from './effect-base.js'; import { ButtonEffect } from './effect-base.js';
import { ColorQuantizer } from './color-quantizer.js';
/** /**
* Animation state class - passed to effects for frame-based rendering * Animation state class - passed to effects for frame-based rendering
*/ */
@ -7,7 +9,7 @@ export class AnimationState {
constructor(frameNumber = 0, totalFrames = 40, fps = 20) { constructor(frameNumber = 0, totalFrames = 40, fps = 20) {
this.frame = frameNumber; this.frame = frameNumber;
this.totalFrames = totalFrames; this.totalFrames = totalFrames;
this.progress = frameNumber / totalFrames; // 0 to 1 this.progress = totalFrames > 1 ? frameNumber / (totalFrames - 1) : 0; // 0 to 1, inclusive of last frame
this.fps = fps; this.fps = fps;
this.time = (frameNumber / fps) * 1000; // milliseconds this.time = (frameNumber / fps) * 1000; // milliseconds
} }
@ -39,6 +41,13 @@ export class ButtonGenerator {
} }
}; };
// GIF export configuration
this.gifConfig = {
quality: config.gifQuality || 1, // 1-30, lower is better quality quantization
dither: config.gifDither || false, // false, 'FloydSteinberg', 'FalseFloydSteinberg', 'Stucki', 'Atkinson'
colorCount: config.gifColorCount || 256, // 2-256, number of colors to reduce to (custom quantization)
};
// Effect registry organized by type // Effect registry organized by type
this.effects = { this.effects = {
transform: [], transform: [],
@ -250,6 +259,7 @@ export class ButtonGenerator {
this.animConfig.fps this.animConfig.fps
); );
this.draw(animState); this.draw(animState);
this.applyPreviewQuantization();
frameNum = (frameNum + 1) % this.animConfig.totalFrames; frameNum = (frameNum + 1) % this.animConfig.totalFrames;
lastFrameTime = currentTime - (elapsed % frameDelay); lastFrameTime = currentTime - (elapsed % frameDelay);
@ -280,15 +290,31 @@ export class ButtonGenerator {
} else { } else {
this.stopAnimatedPreview(); this.stopAnimatedPreview();
this.draw(); this.draw();
this.applyPreviewQuantization();
}
}
/**
* Apply color quantization to preview if enabled
*/
applyPreviewQuantization() {
const colorCount = this.gifConfig.colorCount;
if (colorCount < 256) {
const quantizedData = ColorQuantizer.quantize(this.canvas, colorCount, 'floyd-steinberg');
this.ctx.putImageData(quantizedData, 0, 0);
} }
} }
/** /**
* Export as animated GIF * Export as animated GIF
* @param {Function} progressCallback - Called with progress (0-1) * @param {Function} progressCallback - Called with progress (0-1)
* @param {Object} options - Export options
* @param {number} options.quality - Quality (1-30, lower is better, default: 10)
* @param {boolean|string} options.dither - Dithering algorithm for gif.js
* @param {number} options.colorCount - Number of colors (2-256, default: 256) - uses custom quantization
* @returns {Promise<Blob>} * @returns {Promise<Blob>}
*/ */
async exportAsGif(progressCallback = null) { async exportAsGif(progressCallback = null, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
// Create temporary canvas for frame generation // Create temporary canvas for frame generation
@ -297,14 +323,30 @@ export class ButtonGenerator {
frameCanvas.height = this.canvas.height; frameCanvas.height = this.canvas.height;
const frameCtx = frameCanvas.getContext('2d'); const frameCtx = frameCanvas.getContext('2d');
// Merge options with defaults
const quality = options.quality !== undefined ? options.quality : this.gifConfig.quality;
const gifDither = options.dither !== undefined ? options.dither : this.gifConfig.dither;
const colorCount = options.colorCount !== undefined ? options.colorCount : this.gifConfig.colorCount;
// Determine if we need custom quantization
const useCustomQuantization = colorCount < 256;
const customDither = useCustomQuantization ? 'floyd-steinberg' : false;
// Initialize gif.js // Initialize gif.js
const gif = new GIF({ const gifOptions = {
workers: 2, workers: 2,
quality: 10, quality: quality,
workerScript: '/js/gif.worker.js', workerScript: '/js/gif.worker.js',
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height height: this.canvas.height
}); };
// Add gif.js dither option if specified (only when not using custom quantization)
if (!useCustomQuantization && gifDither !== false) {
gifOptions.dither = gifDither;
}
const gif = new GIF(gifOptions);
// Generate frames // Generate frames
const totalFrames = this.animConfig.totalFrames; const totalFrames = this.animConfig.totalFrames;
@ -328,6 +370,12 @@ export class ButtonGenerator {
tempGenerator.draw(animState); tempGenerator.draw(animState);
// Apply custom color quantization if needed
if (useCustomQuantization) {
const quantizedData = ColorQuantizer.quantize(frameCanvas, colorCount, customDither);
frameCtx.putImageData(quantizedData, 0, 0);
}
gif.addFrame(frameCtx, { gif.addFrame(frameCtx, {
delay: 1000 / this.animConfig.fps, delay: 1000 / this.animConfig.fps,
copy: true copy: true

View file

@ -0,0 +1,263 @@
/**
* Color Quantizer - Custom color reduction with median cut algorithm
* Reduces canvas colors with optional dithering for retro aesthetic
*/
export class ColorQuantizer {
/**
* Reduce colors in a canvas using median cut algorithm
* @param {HTMLCanvasElement} canvas - Canvas to quantize
* @param {number} colorCount - Target number of colors (2-256)
* @param {string|boolean} dither - Dithering algorithm ('floyd-steinberg', false)
* @returns {ImageData} Quantized image data
*/
static quantize(canvas, colorCount = 256, dither = false) {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
if (colorCount >= 256) {
return imageData; // No quantization needed
}
// Build palette using median cut
const palette = this.buildPalette(pixels, colorCount);
// Apply palette to image
if (dither === 'floyd-steinberg') {
this.applyPaletteWithDithering(pixels, palette, canvas.width, canvas.height);
} else {
this.applyPalette(pixels, palette);
}
return imageData;
}
/**
* Build color palette using median cut algorithm
*/
static buildPalette(pixels, colorCount) {
// Collect unique colors
const colorMap = new Map();
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
if (a === 0) continue; // Skip transparent pixels
const key = (r << 16) | (g << 8) | b;
colorMap.set(key, (colorMap.get(key) || 0) + 1);
}
// Convert to array of color objects with counts
const colors = Array.from(colorMap.entries()).map(([key, count]) => ({
r: (key >> 16) & 0xFF,
g: (key >> 8) & 0xFF,
b: key & 0xFF,
count: count
}));
// If we have fewer colors than target, return as-is
if (colors.length <= colorCount) {
return colors.map(c => [c.r, c.g, c.b]);
}
// Start with all colors in one bucket
let buckets = [colors];
// Split buckets until we have desired number
while (buckets.length < colorCount) {
// Find bucket with largest range
let maxRange = -1;
let maxBucketIdx = 0;
let maxChannel = 'r';
buckets.forEach((bucket, idx) => {
if (bucket.length <= 1) return;
const ranges = this.getColorRanges(bucket);
const range = Math.max(ranges.r, ranges.g, ranges.b);
if (range > maxRange) {
maxRange = range;
maxBucketIdx = idx;
if (ranges.r >= ranges.g && ranges.r >= ranges.b) maxChannel = 'r';
else if (ranges.g >= ranges.b) maxChannel = 'g';
else maxChannel = 'b';
}
});
if (maxRange === -1) break; // Can't split further
// Split the bucket
const bucket = buckets[maxBucketIdx];
bucket.sort((a, b) => a[maxChannel] - b[maxChannel]);
const mid = Math.floor(bucket.length / 2);
const bucket1 = bucket.slice(0, mid);
const bucket2 = bucket.slice(mid);
buckets.splice(maxBucketIdx, 1, bucket1, bucket2);
}
// Average colors in each bucket to create palette
return buckets.map(bucket => {
let totalWeight = 0;
let sumR = 0, sumG = 0, sumB = 0;
bucket.forEach(color => {
const weight = color.count;
totalWeight += weight;
sumR += color.r * weight;
sumG += color.g * weight;
sumB += color.b * weight;
});
return [
Math.round(sumR / totalWeight),
Math.round(sumG / totalWeight),
Math.round(sumB / totalWeight)
];
});
}
/**
* Get color ranges in a bucket
*/
static getColorRanges(bucket) {
let minR = 255, maxR = 0;
let minG = 255, maxG = 0;
let minB = 255, maxB = 0;
bucket.forEach(color => {
minR = Math.min(minR, color.r);
maxR = Math.max(maxR, color.r);
minG = Math.min(minG, color.g);
maxG = Math.max(maxG, color.g);
minB = Math.min(minB, color.b);
maxB = Math.max(maxB, color.b);
});
return {
r: maxR - minR,
g: maxG - minG,
b: maxB - minB
};
}
/**
* Find nearest color in palette
*/
static findNearestColor(r, g, b, palette) {
let minDist = Infinity;
let nearest = palette[0];
for (const color of palette) {
const dr = r - color[0];
const dg = g - color[1];
const db = b - color[2];
const dist = dr * dr + dg * dg + db * db;
if (dist < minDist) {
minDist = dist;
nearest = color;
}
}
return nearest;
}
/**
* Apply palette without dithering
*/
static applyPalette(pixels, palette) {
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
if (a === 0) continue;
const nearest = this.findNearestColor(r, g, b, palette);
pixels[i] = nearest[0];
pixels[i + 1] = nearest[1];
pixels[i + 2] = nearest[2];
}
}
/**
* Apply palette with Floyd-Steinberg dithering
*/
static applyPaletteWithDithering(pixels, palette, width, height) {
// Create error buffer
const errors = new Float32Array(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const errIdx = (y * width + x) * 3;
const r = pixels[idx];
const g = pixels[idx + 1];
const b = pixels[idx + 2];
const a = pixels[idx + 3];
if (a === 0) continue;
// Add accumulated error
const newR = Math.max(0, Math.min(255, r + errors[errIdx]));
const newG = Math.max(0, Math.min(255, g + errors[errIdx + 1]));
const newB = Math.max(0, Math.min(255, b + errors[errIdx + 2]));
// Find nearest palette color
const nearest = this.findNearestColor(newR, newG, newB, palette);
// Set pixel to nearest color
pixels[idx] = nearest[0];
pixels[idx + 1] = nearest[1];
pixels[idx + 2] = nearest[2];
// Calculate error
const errR = newR - nearest[0];
const errG = newG - nearest[1];
const errB = newB - nearest[2];
// Distribute error to neighboring pixels (Floyd-Steinberg)
// Right pixel (x+1, y): 7/16
if (x + 1 < width) {
const rightIdx = (y * width + (x + 1)) * 3;
errors[rightIdx] += errR * 7 / 16;
errors[rightIdx + 1] += errG * 7 / 16;
errors[rightIdx + 2] += errB * 7 / 16;
}
// Bottom-left pixel (x-1, y+1): 3/16
if (y + 1 < height && x > 0) {
const blIdx = ((y + 1) * width + (x - 1)) * 3;
errors[blIdx] += errR * 3 / 16;
errors[blIdx + 1] += errG * 3 / 16;
errors[blIdx + 2] += errB * 3 / 16;
}
// Bottom pixel (x, y+1): 5/16
if (y + 1 < height) {
const bottomIdx = ((y + 1) * width + x) * 3;
errors[bottomIdx] += errR * 5 / 16;
errors[bottomIdx + 1] += errG * 5 / 16;
errors[bottomIdx + 2] += errB * 5 / 16;
}
// Bottom-right pixel (x+1, y+1): 1/16
if (y + 1 < height && x + 1 < width) {
const brIdx = ((y + 1) * width + (x + 1)) * 3;
errors[brIdx] += errR * 1 / 16;
errors[brIdx + 1] += errG * 1 / 16;
errors[brIdx + 2] += errB * 1 / 16;
}
}
}
}
}

View file

@ -126,6 +126,9 @@ async function setupApp() {
uiBuilder.buildUI(generator.getAllEffects()); uiBuilder.buildUI(generator.getAllEffects());
uiBuilder.setupConditionalVisibility(); uiBuilder.setupConditionalVisibility();
// Add GIF export settings at the bottom
addGifExportSettings(controlsContainer, generator);
// Preload fonts // Preload fonts
console.log("Loading fonts..."); console.log("Loading fonts...");
await generator.preloadFonts(); await generator.preloadFonts();
@ -148,6 +151,110 @@ async function setupApp() {
console.log("Button Generator ready!"); console.log("Button Generator ready!");
} }
/**
* Add GIF export settings controls
*/
function addGifExportSettings(container, generator) {
const groupDiv = document.createElement('div');
groupDiv.className = 'control-group collapsed';
// Header
const header = document.createElement('h3');
header.className = 'control-group-header';
header.textContent = 'Advanced Settings';
groupDiv.appendChild(header);
const controlsDiv = document.createElement('div');
controlsDiv.className = 'control-group-controls';
// Color count control
const colorsWrapper = document.createElement('div');
colorsWrapper.className = 'control-wrapper';
colorsWrapper.innerHTML = `
<div class="info-text">Note: This only affects frame-by-frame settings, i.e. 8 colours would be 8 colours per frame. I am working on a solution for this.</div>
<label for="gif-colors">
Color Count
<span class="control-description">(16-32 for the 90s experience)</span>
</label>
<div class="range-container">
<input type="range" id="gif-colors" min="2" max="256" step="1" value="256">
<span id="gif-colors-value" class="range-value">256</span>
</div>
`;
controlsDiv.appendChild(colorsWrapper);
// Quality control
const qualityWrapper = document.createElement('div');
qualityWrapper.className = 'control-wrapper';
qualityWrapper.innerHTML = `
<label for="gif-quality">
Quantization Quality
<span class="control-description">(lower = better but slower export)</span>
</label>
<div class="range-container">
<input type="range" id="gif-quality" min="1" max="30" step="1" value="1">
<span id="gif-quality-value" class="range-value">1</span>
</div>
`;
controlsDiv.appendChild(qualityWrapper);
// Dither control
const ditherWrapper = document.createElement('div');
ditherWrapper.className = 'control-wrapper';
ditherWrapper.innerHTML = `
<label for="gif-dither">
Dithering Pattern
<span class="control-description"></span>
</label>
<select id="gif-dither">
<option value="false">None (Smooth)</option>
<option value="FloydSteinberg">Floyd-Steinberg (Classic)</option>
<option value="FalseFloydSteinberg">False Floyd-Steinberg (Fast)</option>
<option value="Stucki">Stucki (Distributed)</option>
<option value="Atkinson">Atkinson (Retro Mac)</option>
</select>
`;
controlsDiv.appendChild(ditherWrapper);
groupDiv.appendChild(controlsDiv);
container.appendChild(groupDiv);
// Setup event listeners for value display
const colorsInput = document.getElementById('gif-colors');
const colorsValue = document.getElementById('gif-colors-value');
colorsInput.addEventListener('input', () => {
colorsValue.textContent = colorsInput.value;
});
const qualityInput = document.getElementById('gif-quality');
const qualityValue = document.getElementById('gif-quality-value');
qualityInput.addEventListener('input', () => {
qualityValue.textContent = qualityInput.value;
});
// Update generator config when changed
colorsInput.addEventListener('change', () => {
generator.gifConfig.colorCount = parseInt(colorsInput.value);
generator.updatePreview(); // Update preview to show color quantization
});
// Also update preview on slider input for real-time feedback
colorsInput.addEventListener('input', () => {
generator.gifConfig.colorCount = parseInt(colorsInput.value);
generator.updatePreview(); // Real-time preview update
});
qualityInput.addEventListener('change', () => {
generator.gifConfig.quality = parseInt(qualityInput.value);
});
const ditherSelect = document.getElementById('gif-dither');
ditherSelect.addEventListener('change', () => {
const value = ditherSelect.value;
generator.gifConfig.dither = value === 'false' ? false : value;
});
}
/** /**
* Setup collapsible section functionality * Setup collapsible section functionality
*/ */