diff --git a/index.html b/index.html index 1d698f0..44d2a2a 100644 --- a/index.html +++ b/index.html @@ -401,11 +401,26 @@ + + + @@ -464,6 +479,21 @@

<< >> - Bit shift left/right

+ - * / % - Math operations

+ +
+

Render Modes

+

Classic - Original colorful mode

+

Grayscale - Black and white

+

Red/Green/Blue - Single color channels

+

HSV - Hue-based coloring

+

Rainbow - Spectrum coloring

+
+ +
+

Export

+

Export PNG - Save current frame as image

+

Export GIF - Save 2-second animation (if animated)

+
diff --git a/src/FakeShader.ts b/src/FakeShader.ts index 89cabc6..a6419f5 100644 --- a/src/FakeShader.ts +++ b/src/FakeShader.ts @@ -5,6 +5,7 @@ interface WorkerMessage { width?: number; height?: number; time?: number; + renderMode?: string; } interface WorkerResponse { @@ -25,6 +26,7 @@ export class FakeShader { private isCompiled: boolean = false; private isRendering: boolean = false; private pendingRenders: string[] = []; + private renderMode: string = 'classic'; // Frame rate limiting private targetFPS: number = 30; @@ -111,7 +113,8 @@ export class FakeShader { type: 'render', width: this.canvas.width, height: this.canvas.height, - time: currentTime + time: currentTime, + renderMode: this.renderMode } as WorkerMessage); } @@ -179,6 +182,10 @@ export class FakeShader { this.frameInterval = 1000 / this.targetFPS; } + setRenderMode(mode: string): void { + this.renderMode = mode; + } + destroy(): void { this.stopAnimation(); this.worker.terminate(); diff --git a/src/ShaderWorker.ts b/src/ShaderWorker.ts index 17fc44f..0bc34cd 100644 --- a/src/ShaderWorker.ts +++ b/src/ShaderWorker.ts @@ -6,6 +6,7 @@ interface WorkerMessage { width?: number; height?: number; time?: number; + renderMode?: string; } interface WorkerResponse { @@ -33,7 +34,7 @@ class ShaderWorker { this.compileShader(message.id, message.code!); break; case 'render': - this.renderShader(message.id, message.width!, message.height!, message.time!); + this.renderShader(message.id, message.width!, message.height!, message.time!, message.renderMode || 'classic'); break; } } catch (error) { @@ -75,7 +76,7 @@ class ShaderWorker { } } - private renderShader(id: string, width: number, height: number, time: number): void { + private renderShader(id: string, width: number, height: number, time: number, renderMode: string): void { if (!this.compiledFunction) { this.postError(id, 'No compiled shader'); return; @@ -110,12 +111,12 @@ class ShaderWorker { try { const value = this.compiledFunction(x, y, time, pixelIndex); const safeValue = isFinite(value) ? value : 0; - const color = Math.abs(safeValue) % 256; + const [r, g, b] = this.calculateColor(safeValue, renderMode); - data[i] = color; // R - data[i + 1] = (color * 2) % 256; // G - data[i + 2] = (color * 3) % 256; // B - data[i + 3] = 255; // A + data[i] = r; // R + data[i + 1] = g; // G + data[i + 2] = b; // B + data[i + 3] = 255; // A } catch (error) { data[i] = 0; // R data[i + 1] = 0; // G @@ -131,6 +132,93 @@ class ShaderWorker { } } + private calculateColor(value: number, renderMode: string): [number, number, number] { + const absValue = Math.abs(value) % 256; + + switch (renderMode) { + case 'classic': + return [ + absValue, + (absValue * 2) % 256, + (absValue * 3) % 256 + ]; + + case 'grayscale': + return [absValue, absValue, absValue]; + + case 'red': + return [absValue, 0, 0]; + + case 'green': + return [0, absValue, 0]; + + case 'blue': + return [0, 0, absValue]; + + case 'rgb': + return [ + (absValue * 255 / 256) | 0, + ((absValue * 2) % 256 * 255 / 256) | 0, + ((absValue * 3) % 256 * 255 / 256) | 0 + ]; + + case 'hsv': + return this.hsvToRgb(absValue / 255.0, 1.0, 1.0); + + case 'rainbow': + return this.rainbowColor(absValue); + + default: + return [absValue, absValue, absValue]; + } + } + + private hsvToRgb(h: number, s: number, v: number): [number, number, number] { + const c = v * s; + const x = c * (1 - Math.abs((h * 6) % 2 - 1)); + const m = v - c; + + let r = 0, g = 0, b = 0; + + if (h < 1/6) { + r = c; g = x; b = 0; + } else if (h < 2/6) { + r = x; g = c; b = 0; + } else if (h < 3/6) { + r = 0; g = c; b = x; + } else if (h < 4/6) { + r = 0; g = x; b = c; + } else if (h < 5/6) { + r = x; g = 0; b = c; + } else { + r = c; g = 0; b = x; + } + + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255) + ]; + } + + private rainbowColor(value: number): [number, number, number] { + const phase = (value / 255.0) * 6; + const segment = Math.floor(phase); + const remainder = phase - segment; + const t = remainder; + const q = 1 - t; + + switch (segment % 6) { + case 0: return [255, Math.round(t * 255), 0]; + case 1: return [Math.round(q * 255), 255, 0]; + case 2: return [0, 255, Math.round(t * 255)]; + case 3: return [0, Math.round(q * 255), 255]; + case 4: return [Math.round(t * 255), 0, 255]; + case 5: return [255, 0, Math.round(q * 255)]; + default: return [255, 255, 255]; + } + } + private sanitizeCode(code: string): string { // Strict whitelist approach const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/; diff --git a/src/Storage.ts b/src/Storage.ts index de81bfd..6f9b5f3 100644 --- a/src/Storage.ts +++ b/src/Storage.ts @@ -10,6 +10,7 @@ interface AppSettings { resolution: number; fps: number; lastShaderCode: string; + renderMode: string; } export class Storage { @@ -95,7 +96,8 @@ export class Storage { const defaults: AppSettings = { resolution: 1, fps: 30, - lastShaderCode: 'x^y' + lastShaderCode: 'x^y', + renderMode: 'classic' }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; } catch (error) { @@ -103,7 +105,8 @@ export class Storage { return { resolution: 1, fps: 30, - lastShaderCode: 'x^y' + lastShaderCode: 'x^y', + renderMode: 'classic' }; } } diff --git a/src/main.ts b/src/main.ts index 1047bf3..94b1903 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,8 +52,11 @@ class BitfielderApp { const showUiBtn = document.getElementById('show-ui-btn')!; const randomBtn = document.getElementById('random-btn')!; const shareBtn = document.getElementById('share-btn')!; + const exportPngBtn = document.getElementById('export-png-btn')!; + const exportGifBtn = document.getElementById('export-gif-btn')!; const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement; const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement; + const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement; const helpPopup = document.getElementById('help-popup')!; const closeBtn = helpPopup.querySelector('.close-btn')!; @@ -68,8 +71,11 @@ class BitfielderApp { showUiBtn.addEventListener('click', () => this.showUI()); randomBtn.addEventListener('click', () => this.generateRandom()); shareBtn.addEventListener('click', () => this.shareURL()); + exportPngBtn.addEventListener('click', () => this.exportPNG()); + exportGifBtn.addEventListener('click', () => this.exportGIF()); resolutionSelect.addEventListener('change', () => this.updateResolution()); fpsSelect.addEventListener('change', () => this.updateFPS()); + renderModeSelect.addEventListener('change', () => this.updateRenderMode()); closeBtn.addEventListener('click', () => this.hideHelp()); // Library events @@ -251,12 +257,110 @@ class BitfielderApp { Storage.saveSettings({ fps }); } + private updateRenderMode(): void { + const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement; + const renderMode = renderModeSelect.value; + this.shader.setRenderMode(renderMode); + this.render(); + Storage.saveSettings({ renderMode }); + } + + private exportPNG(): void { + const link = document.createElement('a'); + link.download = `bitfielder-${Date.now()}.png`; + link.href = this.canvas.toDataURL('image/png'); + link.click(); + } + + private exportGIF(): void { + const hasAnimation = this.editor.value.includes('t'); + if (!hasAnimation) { + // Static image - just export as PNG + this.exportPNG(); + return; + } + + // For animated GIFs, we need to capture multiple frames + this.captureGIF(); + } + + private async captureGIF(): Promise { + const hasAnimation = this.editor.value.includes('t'); + if (!hasAnimation) { + this.exportPNG(); + return; + } + + // Show progress indicator + const exportBtn = document.getElementById('export-gif-btn')!; + const originalText = exportBtn.textContent; + exportBtn.textContent = 'Capturing...'; + exportBtn.setAttribute('disabled', 'true'); + + try { + // Import GIF.js dynamically + const { default: GIF } = await import('https://cdn.skypack.dev/gif.js@0.2.0'); + + const gif = new GIF({ + workers: 2, + quality: 10, + width: this.canvas.width, + height: this.canvas.height, + workerScript: 'https://cdn.skypack.dev/gif.js@0.2.0/dist/gif.worker.js' + }); + + const frameDuration = 1000 / 30; // 30 FPS + const totalFrames = 60; // 2 seconds at 30 FPS + const frameInterval = 1000 / 30; + + // Capture frames + for (let frame = 0; frame < totalFrames; frame++) { + exportBtn.textContent = `Capturing ${frame + 1}/${totalFrames}`; + + // Wait for frame to render + await new Promise(resolve => setTimeout(resolve, frameInterval)); + + // Create a copy of the canvas for this frame + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = this.canvas.width; + tempCanvas.height = this.canvas.height; + const tempCtx = tempCanvas.getContext('2d')!; + tempCtx.drawImage(this.canvas, 0, 0); + + gif.addFrame(tempCanvas, { delay: frameDuration }); + } + + exportBtn.textContent = 'Rendering GIF...'; + + gif.on('finished', (blob: Blob) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.download = `bitfielder-${Date.now()}.gif`; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + + exportBtn.textContent = originalText; + exportBtn.removeAttribute('disabled'); + }); + + gif.render(); + } catch (error) { + console.error('GIF export failed:', error); + exportBtn.textContent = originalText; + exportBtn.removeAttribute('disabled'); + // Fallback to PNG + this.exportPNG(); + } + } + private loadSettings(): void { const settings = Storage.getSettings(); // Apply settings to UI (document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString(); (document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.toString(); + (document.getElementById('render-mode-select') as HTMLSelectElement).value = settings.renderMode || 'classic'; // Load last shader code if no URL hash if (!window.location.hash) {