new features
This commit is contained in:
30
index.html
30
index.html
@ -401,11 +401,26 @@
|
||||
<option value="60">60 FPS</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
|
||||
Render Mode:
|
||||
<select id="render-mode-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
|
||||
<option value="classic" selected>Classic</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
<option value="red">Red Channel</option>
|
||||
<option value="green">Green Channel</option>
|
||||
<option value="blue">Blue Channel</option>
|
||||
<option value="rgb">RGB Split</option>
|
||||
<option value="hsv">HSV</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="help-btn">?</button>
|
||||
<button id="fullscreen-btn">Fullscreen</button>
|
||||
<button id="hide-ui-btn">Hide UI</button>
|
||||
<button id="random-btn">Random</button>
|
||||
<button id="share-btn">Share</button>
|
||||
<button id="export-png-btn">Export PNG</button>
|
||||
<button id="export-gif-btn">Export GIF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -464,6 +479,21 @@
|
||||
<p><strong><< >></strong> - Bit shift left/right</p>
|
||||
<p><strong>+ - * / %</strong> - Math operations</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Render Modes</h4>
|
||||
<p><strong>Classic</strong> - Original colorful mode</p>
|
||||
<p><strong>Grayscale</strong> - Black and white</p>
|
||||
<p><strong>Red/Green/Blue</strong> - Single color channels</p>
|
||||
<p><strong>HSV</strong> - Hue-based coloring</p>
|
||||
<p><strong>Rainbow</strong> - Spectrum coloring</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Export</h4>
|
||||
<p><strong>Export PNG</strong> - Save current frame as image</p>
|
||||
<p><strong>Export GIF</strong> - Save 2-second animation (if animated)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="performance-warning">
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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]+$/;
|
||||
|
||||
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
104
src/main.ts
104
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<void> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user