adding advanced settings
This commit is contained in:
parent
5a004981a9
commit
b27f9cfb39
3 changed files with 423 additions and 5 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { ButtonEffect } from './effect-base.js';
|
||||
|
||||
import { ColorQuantizer } from './color-quantizer.js';
|
||||
|
||||
/**
|
||||
* Animation state class - passed to effects for frame-based rendering
|
||||
*/
|
||||
|
|
@ -7,7 +9,7 @@ export class AnimationState {
|
|||
constructor(frameNumber = 0, totalFrames = 40, fps = 20) {
|
||||
this.frame = frameNumber;
|
||||
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.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
|
||||
this.effects = {
|
||||
transform: [],
|
||||
|
|
@ -250,6 +259,7 @@ export class ButtonGenerator {
|
|||
this.animConfig.fps
|
||||
);
|
||||
this.draw(animState);
|
||||
this.applyPreviewQuantization();
|
||||
|
||||
frameNum = (frameNum + 1) % this.animConfig.totalFrames;
|
||||
lastFrameTime = currentTime - (elapsed % frameDelay);
|
||||
|
|
@ -280,15 +290,31 @@ export class ButtonGenerator {
|
|||
} else {
|
||||
this.stopAnimatedPreview();
|
||||
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
|
||||
* @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>}
|
||||
*/
|
||||
async exportAsGif(progressCallback = null) {
|
||||
async exportAsGif(progressCallback = null, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Create temporary canvas for frame generation
|
||||
|
|
@ -297,14 +323,30 @@ export class ButtonGenerator {
|
|||
frameCanvas.height = this.canvas.height;
|
||||
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
|
||||
const gif = new GIF({
|
||||
const gifOptions = {
|
||||
workers: 2,
|
||||
quality: 10,
|
||||
quality: quality,
|
||||
workerScript: '/js/gif.worker.js',
|
||||
width: this.canvas.width,
|
||||
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
|
||||
const totalFrames = this.animConfig.totalFrames;
|
||||
|
|
@ -328,6 +370,12 @@ export class ButtonGenerator {
|
|||
|
||||
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, {
|
||||
delay: 1000 / this.animConfig.fps,
|
||||
copy: true
|
||||
|
|
|
|||
263
static/js/button-generator/color-quantizer.js
Normal file
263
static/js/button-generator/color-quantizer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +126,9 @@ async function setupApp() {
|
|||
uiBuilder.buildUI(generator.getAllEffects());
|
||||
uiBuilder.setupConditionalVisibility();
|
||||
|
||||
// Add GIF export settings at the bottom
|
||||
addGifExportSettings(controlsContainer, generator);
|
||||
|
||||
// Preload fonts
|
||||
console.log("Loading fonts...");
|
||||
await generator.preloadFonts();
|
||||
|
|
@ -148,6 +151,110 @@ async function setupApp() {
|
|||
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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue