Autochrome Overlay Tool
Clumping may take time. Please wait for it to finish.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Autochrome Overlay Tool (GPU Blend, CPU Clump)</title>
<style>
body { font-family: sans-serif; background: #111; color: #fff; text-align: center; padding: 20px; }
canvas { margin-top: 10px; border: 1px solid #555; image-rendering: pixelated; }
#info { margin-top: 10px; font-size: 14px; color: #ccc; white-space: pre-line; }
#warning { font-size: 13px; color: #f88; }
input, button, label { margin: 8px; padding: 8px; font-size: 14px; }
</style>
</head>
<body>
<h1>Autochrome Overlay Tool</h1>
<label>Base Image: <input type="file" id="baseImgInput" accept="image/*"></label><br>
<label>Color 1: <input type="color" id="color1" value="#ff9600"></label>
<label>Color 2: <input type="color" id="color2" value="#64c832"></label>
<label>Color 3: <input type="color" id="color3" value="#9600ff"></label><br>
<label>Effect Strength: <input type="range" id="effectSlider" min="0" max="100" value="100"></label><br>
<button id="newScreenBtn" disabled>New Screen</button>
<button id="moreBtn" disabled>Clump</button>
<div id="warning">Clumping may take time. Please wait for it to finish.</div>
<button id="saveBtn" disabled>Download</button>
<div id="info"></div>
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true });
if (!gl) alert('WebGL2 not supported');
const vertexSrc = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0, 1);
}`;
const fragmentSrc = `#version 300 es
precision highp float;
in vec2 v_uv;
uniform sampler2D u_base;
uniform sampler2D u_screen;
uniform float u_strength;
out vec4 outColor;
void main() {
vec2 flippedUV = vec2(v_uv.x, 1.0 - v_uv.y);
vec4 base = texture(u_base, flippedUV);
vec4 screen = texture(u_screen, flippedUV);
outColor.rgb = mix(base.rgb, base.rgb * screen.rgb, u_strength);
outColor.a = 1.0;
}`;
function compileShader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
return null;
}
return shader;
}
function createProgram(vsSrc, fsSrc) {
const vs = compileShader(gl.VERTEX_SHADER, vsSrc);
const fs = compileShader(gl.FRAGMENT_SHADER, fsSrc);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(prog));
return null;
}
return prog;
}
const program = createProgram(vertexSrc, fragmentSrc);
gl.useProgram(program);
const positionLoc = gl.getAttribLocation(program, 'a_position');
const strengthLoc = gl.getUniformLocation(program, 'u_strength');
const baseLoc = gl.getUniformLocation(program, 'u_base');
const screenLoc = gl.getUniformLocation(program, 'u_screen');
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
function createTexture(image) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tex;
}
const baseImgInput = document.getElementById('baseImgInput');
const newScreenBtn = document.getElementById('newScreenBtn');
const moreBtn = document.getElementById('moreBtn');
const saveBtn = document.getElementById('saveBtn');
const info = document.getElementById('info');
const color1 = document.getElementById('color1');
const color2 = document.getElementById('color2');
const color3 = document.getElementById('color3');
const effectSlider = document.getElementById('effectSlider');
let baseImage = null;
let screenCanvas = null;
let screenCtx = null;
let clumpIterations = 0;
let totalSwaps = 0;
let targetSwaps = 0;
let baseTex = null, screenTex = null;
function hexToRGB(hex) {
const bigint = parseInt(hex.slice(1), 16);
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
}
function loadImage(file, callback) {
const img = new Image();
img.onload = () => callback(img);
img.src = URL.createObjectURL(file);
}
function generateRandomScreen(width, height, colors) {
const sc = document.createElement('canvas');
sc.width = width;
sc.height = height;
const sctx = sc.getContext('2d');
const imgData = sctx.createImageData(width, height);
for (let i = 0; i < imgData.data.length; i += 4) {
const color = colors[Math.floor(Math.random() * colors.length)];
imgData.data[i] = color[0];
imgData.data[i + 1] = color[1];
imgData.data[i + 2] = color[2];
imgData.data[i + 3] = 255;
}
sctx.putImageData(imgData, 0, 0);
return sc;
}
async function clumpScreen(passes) {
const width = screenCanvas.width;
const height = screenCanvas.height;
const imgData = screenCtx.getImageData(0, 0, width, height);
const data = imgData.data;
let swaps = 0;
for (let i = 0; i < passes; i++) {
const x = Math.floor(Math.random() * width);
const y = Math.floor(Math.random() * height);
const cIdx = (y * width + x) * 4;
const cr = data[cIdx], cg = data[cIdx + 1], cb = data[cIdx + 2];
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const nx = (x + dx + width) % width;
const ny = (y + dy + height) % height;
const nIdx = (ny * width + nx) * 4;
if (data[nIdx] === cr && data[nIdx + 1] === cg && data[nIdx + 2] === cb) {
const sx = (x + Math.sign(dx) + width) % width;
const sy = (y + Math.sign(dy) + height) % height;
const sIdx = (sy * width + sx) * 4;
for (let j = 0; j < 4; j++) {
const tmp = data[sIdx + j];
data[sIdx + j] = data[nIdx + j];
data[nIdx + j] = tmp;
}
swaps++;
}
}
}
}
screenCtx.putImageData(imgData, 0, 0);
screenTex = createTexture(screenCanvas);
return swaps;
}
function renderOverlay() {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, baseTex);
gl.uniform1i(baseLoc, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, screenTex);
gl.uniform1i(screenLoc, 1);
gl.uniform1f(strengthLoc, effectSlider.value / 100);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function generateNewScreen() {
const colors = [hexToRGB(color1.value), hexToRGB(color2.value), hexToRGB(color3.value)];
screenCanvas = generateRandomScreen(canvas.width, canvas.height, colors);
screenCtx = screenCanvas.getContext('2d');
screenTex = createTexture(screenCanvas);
clumpIterations = 0;
totalSwaps = 0;
targetSwaps = canvas.width * canvas.height * 20;
renderOverlay();
updateInfo();
}
function updateInfo(currentPassSwaps = 0, totalThisRun = targetSwaps) {
const percent = Math.min((totalSwaps / totalThisRun) * 100, 100).toFixed(1);
info.textContent = `Hi!`;
}
baseImgInput.addEventListener('change', () => {
const file = baseImgInput.files[0];
if (file) {
loadImage(file, img => {
baseImage = img;
canvas.width = img.width;
canvas.height = img.height;
baseTex = createTexture(img);
generateNewScreen();
newScreenBtn.disabled = false;
moreBtn.disabled = false;
saveBtn.disabled = false;
});
}
});
newScreenBtn.addEventListener('click', () => {
generateNewScreen();
});
moreBtn.addEventListener('click', async () => {
totalSwaps = 0;
targetSwaps = canvas.width * canvas.height * 20;
moreBtn.disabled = true;
const totalPasses = Math.floor(canvas.width * canvas.height * 0.01);
let donePasses = 0;
let passBatch = 5000;
let passSwaps = 0;
while (donePasses < totalPasses) {
const batchSize = Math.min(passBatch, totalPasses - donePasses);
passSwaps = await clumpScreen(batchSize);
donePasses += batchSize;
totalSwaps += passSwaps;
clumpIterations++;
updateInfo(passSwaps, totalPasses);
renderOverlay();
const percent = Math.floor((donePasses / totalPasses) * 100);
moreBtn.textContent = `Clump (${percent}%)`;
await new Promise(resolve => setTimeout(resolve, 20));
}
moreBtn.textContent = "Clump (100%)";
moreBtn.disabled = false;
});
effectSlider.addEventListener('input', renderOverlay);
saveBtn.addEventListener('click', () => {
const width = canvas.width;
const height = canvas.height;
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const ctx = outputCanvas.getContext('2d');
const imageData = ctx.createImageData(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const j = ((height - y - 1) * width + x) * 4;
imageData.data[i] = pixels[j];
imageData.data[i + 1] = pixels[j + 1];
imageData.data[i + 2] = pixels[j + 2];
imageData.data[i + 3] = pixels[j + 3];
}
}
ctx.putImageData(imageData, 0, 0);
const link = document.createElement('a');
link.download = 'autochrome_output.png';
link.href = outputCanvas.toDataURL('image/png');
link.click();
});
color1.addEventListener('input', generateNewScreen);
color2.addEventListener('input', generateNewScreen);
color3.addEventListener('input', generateNewScreen);
function updateColorIndicators() {
color1.style.backgroundColor = color1.value;
color2.style.backgroundColor = color2.value;
color3.style.backgroundColor = color3.value;
}
color1.addEventListener('input', () => {
updateColorIndicators();
generateNewScreen();
});
color2.addEventListener('input', () => {
updateColorIndicators();
generateNewScreen();
});
color3.addEventListener('input', () => {
updateColorIndicators();
generateNewScreen();
});
updateColorIndicators();
</script>
</body>
</html>