new features

This commit is contained in:
2025-07-05 00:55:57 +00:00
parent 083abb53d4
commit 9090ea4c10
5 changed files with 242 additions and 10 deletions

View File

@ -401,11 +401,26 @@
<option value="60">60 FPS</option> <option value="60">60 FPS</option>
</select> </select>
</label> </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="help-btn">?</button>
<button id="fullscreen-btn">Fullscreen</button> <button id="fullscreen-btn">Fullscreen</button>
<button id="hide-ui-btn">Hide UI</button> <button id="hide-ui-btn">Hide UI</button>
<button id="random-btn">Random</button> <button id="random-btn">Random</button>
<button id="share-btn">Share</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>
</div> </div>
@ -464,6 +479,21 @@
<p><strong>&lt;&lt; &gt;&gt;</strong> - Bit shift left/right</p> <p><strong>&lt;&lt; &gt;&gt;</strong> - Bit shift left/right</p>
<p><strong>+ - * / %</strong> - Math operations</p> <p><strong>+ - * / %</strong> - Math operations</p>
</div> </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>
<div id="performance-warning"> <div id="performance-warning">

View File

@ -5,6 +5,7 @@ interface WorkerMessage {
width?: number; width?: number;
height?: number; height?: number;
time?: number; time?: number;
renderMode?: string;
} }
interface WorkerResponse { interface WorkerResponse {
@ -25,6 +26,7 @@ export class FakeShader {
private isCompiled: boolean = false; private isCompiled: boolean = false;
private isRendering: boolean = false; private isRendering: boolean = false;
private pendingRenders: string[] = []; private pendingRenders: string[] = [];
private renderMode: string = 'classic';
// Frame rate limiting // Frame rate limiting
private targetFPS: number = 30; private targetFPS: number = 30;
@ -111,7 +113,8 @@ export class FakeShader {
type: 'render', type: 'render',
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height, height: this.canvas.height,
time: currentTime time: currentTime,
renderMode: this.renderMode
} as WorkerMessage); } as WorkerMessage);
} }
@ -179,6 +182,10 @@ export class FakeShader {
this.frameInterval = 1000 / this.targetFPS; this.frameInterval = 1000 / this.targetFPS;
} }
setRenderMode(mode: string): void {
this.renderMode = mode;
}
destroy(): void { destroy(): void {
this.stopAnimation(); this.stopAnimation();
this.worker.terminate(); this.worker.terminate();

View File

@ -6,6 +6,7 @@ interface WorkerMessage {
width?: number; width?: number;
height?: number; height?: number;
time?: number; time?: number;
renderMode?: string;
} }
interface WorkerResponse { interface WorkerResponse {
@ -33,7 +34,7 @@ class ShaderWorker {
this.compileShader(message.id, message.code!); this.compileShader(message.id, message.code!);
break; break;
case 'render': 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; break;
} }
} catch (error) { } 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) { if (!this.compiledFunction) {
this.postError(id, 'No compiled shader'); this.postError(id, 'No compiled shader');
return; return;
@ -110,12 +111,12 @@ class ShaderWorker {
try { try {
const value = this.compiledFunction(x, y, time, pixelIndex); const value = this.compiledFunction(x, y, time, pixelIndex);
const safeValue = isFinite(value) ? value : 0; 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] = r; // R
data[i + 1] = (color * 2) % 256; // G data[i + 1] = g; // G
data[i + 2] = (color * 3) % 256; // B data[i + 2] = b; // B
data[i + 3] = 255; // A data[i + 3] = 255; // A
} catch (error) { } catch (error) {
data[i] = 0; // R data[i] = 0; // R
data[i + 1] = 0; // G 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 { private sanitizeCode(code: string): string {
// Strict whitelist approach // Strict whitelist approach
const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/; const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/;

View File

@ -10,6 +10,7 @@ interface AppSettings {
resolution: number; resolution: number;
fps: number; fps: number;
lastShaderCode: string; lastShaderCode: string;
renderMode: string;
} }
export class Storage { export class Storage {
@ -95,7 +96,8 @@ export class Storage {
const defaults: AppSettings = { const defaults: AppSettings = {
resolution: 1, resolution: 1,
fps: 30, fps: 30,
lastShaderCode: 'x^y' lastShaderCode: 'x^y',
renderMode: 'classic'
}; };
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (error) { } catch (error) {
@ -103,7 +105,8 @@ export class Storage {
return { return {
resolution: 1, resolution: 1,
fps: 30, fps: 30,
lastShaderCode: 'x^y' lastShaderCode: 'x^y',
renderMode: 'classic'
}; };
} }
} }

View File

@ -52,8 +52,11 @@ class BitfielderApp {
const showUiBtn = document.getElementById('show-ui-btn')!; const showUiBtn = document.getElementById('show-ui-btn')!;
const randomBtn = document.getElementById('random-btn')!; const randomBtn = document.getElementById('random-btn')!;
const shareBtn = document.getElementById('share-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 resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
const fpsSelect = document.getElementById('fps-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 helpPopup = document.getElementById('help-popup')!;
const closeBtn = helpPopup.querySelector('.close-btn')!; const closeBtn = helpPopup.querySelector('.close-btn')!;
@ -68,8 +71,11 @@ class BitfielderApp {
showUiBtn.addEventListener('click', () => this.showUI()); showUiBtn.addEventListener('click', () => this.showUI());
randomBtn.addEventListener('click', () => this.generateRandom()); randomBtn.addEventListener('click', () => this.generateRandom());
shareBtn.addEventListener('click', () => this.shareURL()); shareBtn.addEventListener('click', () => this.shareURL());
exportPngBtn.addEventListener('click', () => this.exportPNG());
exportGifBtn.addEventListener('click', () => this.exportGIF());
resolutionSelect.addEventListener('change', () => this.updateResolution()); resolutionSelect.addEventListener('change', () => this.updateResolution());
fpsSelect.addEventListener('change', () => this.updateFPS()); fpsSelect.addEventListener('change', () => this.updateFPS());
renderModeSelect.addEventListener('change', () => this.updateRenderMode());
closeBtn.addEventListener('click', () => this.hideHelp()); closeBtn.addEventListener('click', () => this.hideHelp());
// Library events // Library events
@ -251,12 +257,110 @@ class BitfielderApp {
Storage.saveSettings({ fps }); 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 { private loadSettings(): void {
const settings = Storage.getSettings(); const settings = Storage.getSettings();
// Apply settings to UI // Apply settings to UI
(document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString(); (document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString();
(document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.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 // Load last shader code if no URL hash
if (!window.location.hash) { if (!window.location.hash) {