(() => {
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const dropCard = document.getElementById('drop-card');
const editorCard = document.getElementById('editor-card');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const grainSlider = document.getElementById('grain');
const vignetteSlider = document.getElementById('vignette');
const halationSlider = document.getElementById('halation');
const leakSlider = document.getElementById('leak');
const rerollLeakBtn = document.getElementById('reroll-leak');
const presetRow = document.getElementById('preset-row');
const resetBtn = document.getElementById('reset-btn');
const newPhotoBtn = document.getElementById('new-photo-btn');
const downloadBtn = document.getElementById('download-btn');
const shareBtn = document.getElementById('share-btn');
const developBtn = document.getElementById('develop-btn');
const resultPanel = document.getElementById('result-panel');
// Offscreen canvases reused across renders so we're not allocating per frame.
const work = document.createElement('canvas');
const workCtx = work.getContext('2d');
const bloom = document.createElement('canvas');
const bloomCtx = bloom.getContext('2d');
const noiseCanvas = document.createElement('canvas');
const noiseCtx = noiseCanvas.getContext('2d');
let originalImg = null;
const PRESETS = {
none: { filter: 'none' },
// "pick a vibe" presets rather than raw white-balance sliders, per
// PROPOSAL.md — expressed as CSS filter strings applied at draw time.
warm: { filter: 'sepia(0.6) saturate(1.8) hue-rotate(-15deg) contrast(1.1) brightness(1.05)' },
cool: { filter: 'saturate(1.9) hue-rotate(165deg) contrast(1.2) brightness(0.9)' },
faded: { filter: 'sepia(0.2) saturate(0.35) contrast(0.7) brightness(1.15)' },
};
const params = {
preset: 'none',
grain: 0,
vignette: 0,
halation: 0,
leak: 0,
leakSeed: Math.random(),
};
function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
originalImg = img;
URL.revokeObjectURL(url);
const maxDim = 2400; // cap so a raw phone photo doesn't choke canvas ops
let w = img.naturalWidth;
let h = img.naturalHeight;
if (Math.max(w, h) > maxDim) {
const scale = maxDim / Math.max(w, h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
canvas.width = w;
canvas.height = h;
work.width = w;
work.height = h;
bloom.width = w;
bloom.height = h;
dropCard.hidden = true;
editorCard.hidden = false;
resultPanel.hidden = true;
resetParams();
render();
};
img.src = url;
}
function resetParams() {
params.preset = 'none';
params.grain = 0;
params.vignette = 0;
params.halation = 0;
params.leak = 0;
params.leakSeed = Math.random();
grainSlider.value = 0;
vignetteSlider.value = 0;
halationSlider.value = 0;
leakSlider.value = 0;
updateValueLabels();
[...presetRow.children].forEach(b => b.classList.toggle('active', b.dataset.preset === 'none'));
}
function updateValueLabels() {
document.getElementById('grain-value').textContent = params.grain;
document.getElementById('vignette-value').textContent = params.vignette;
document.getElementById('halation-value').textContent = params.halation;
document.getElementById('leak-value').textContent = params.leak;
}
// Recomputes the full stack from the original image every time — never
// chains operations onto already-processed pixels — so dragging any
// slider back to 0 reproduces the source exactly.
function render() {
if (!originalImg) return;
const w = canvas.width;
const h = canvas.height;
workCtx.filter = PRESETS[params.preset].filter;
workCtx.globalCompositeOperation = 'source-over';
workCtx.clearRect(0, 0, w, h);
workCtx.drawImage(originalImg, 0, 0, w, h);
workCtx.filter = 'none';
if (params.halation > 0) applyHalation(w, h);
if (params.vignette > 0) applyVignette(w, h);
if (params.leak > 0) applyLightLeak(w, h);
if (params.grain > 0) applyGrain(w, h);
ctx.clearRect(0, 0, w, h);
ctx.drawImage(work, 0, 0);
}
// Bloom around bright areas: blur a thresholded bright-pass and screen it
// back over the original.
function applyHalation(w, h) {
bloomCtx.clearRect(0, 0, w, h);
bloomCtx.filter = 'brightness(180%) contrast(400%) saturate(120%)';
bloomCtx.drawImage(work, 0, 0);
bloomCtx.filter = 'none';
const blurPx = Math.max(4, Math.round(Math.min(w, h) * 0.02));
bloomCtx.filter = `blur(${blurPx}px)`;
bloomCtx.drawImage(bloom, 0, 0);
bloomCtx.filter = 'none';
workCtx.save();
workCtx.globalCompositeOperation = 'screen';
workCtx.globalAlpha = (params.halation / 100) * 0.85;
workCtx.drawImage(bloom, 0, 0);
workCtx.restore();
}
function applyVignette(w, h) {
const amount = params.vignette / 100;
const cx = w / 2;
const cy = h / 2;
const outerR = Math.hypot(cx, cy);
const grad = workCtx.createRadialGradient(cx, cy, outerR * 0.35, cx, cy, outerR);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, `rgba(0,0,0,${amount * 0.85})`);
workCtx.save();
workCtx.globalCompositeOperation = 'multiply';
workCtx.fillStyle = grad;
workCtx.fillRect(0, 0, w, h);
workCtx.restore();
}
// Warm streak/bloom near a randomized edge point, rotated per seed so
// repeat use doesn't look identical — no pre-baked PNG assets needed,
// it's a procedural gradient.
function applyLightLeak(w, h) {
const seed = params.leakSeed;
const angle = seed * Math.PI * 2;
const edgeX = w / 2 + Math.cos(angle) * w * 0.7;
const edgeY = h / 2 + Math.sin(angle) * h * 0.7;
const radius = Math.max(w, h) * (0.5 + seed * 0.3);
const grad = workCtx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, radius);
const hue = 25 + seed * 30; // warm amber/red range
grad.addColorStop(0, `hsla(${hue}, 90%, 60%, ${(params.leak / 100) * 0.9})`);
grad.addColorStop(0.5, `hsla(${hue + 10}, 90%, 55%, ${(params.leak / 100) * 0.35})`);
grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)');
workCtx.save();
workCtx.globalCompositeOperation = 'screen';
workCtx.fillStyle = grad;
workCtx.fillRect(0, 0, w, h);
workCtx.restore();
}
function applyGrain(w, h) {
// Noise is generated at reduced resolution and scaled up — a
// per-pixel random overlay at full phone-photo resolution is
// needlessly slow and reads no differently once blended in.
const nw = Math.max(1, Math.round(w / 3));
const nh = Math.max(1, Math.round(h / 3));
noiseCanvas.width = nw;
noiseCanvas.height = nh;
const imgData = noiseCtx.createImageData(nw, nh);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const v = Math.random() * 255;
d[i] = v;
d[i + 1] = v;
d[i + 2] = v;
d[i + 3] = 255;
}
noiseCtx.putImageData(imgData, 0, 0);
workCtx.save();
workCtx.globalCompositeOperation = 'overlay';
workCtx.globalAlpha = (params.grain / 100) * 0.6;
workCtx.imageSmoothingEnabled = false;
workCtx.drawImage(noiseCanvas, 0, 0, w, h);
workCtx.restore();
}
function showResult(html, isError) {
resultPanel.innerHTML = html;
resultPanel.classList.toggle('error', !!isError);
resultPanel.hidden = false;
}
function canvasToBlob() {
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92));
}
async function upload(delayed) {
const btn = delayed ? developBtn : shareBtn;
btn.disabled = true;
showResult('uploading…');
try {
const blob = await canvasToBlob();
const res = await fetch(`/api/upload?delayed=${delayed}`, {
method: 'POST',
headers: { 'Content-Type': 'image/jpeg' },
body: blob,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `upload failed (${res.status})`);
}
const data = await res.json();
const link = `${location.origin}${data.url}`;
if (delayed) {
showResult(
`sent to develop. link: ${link}
` +
`ready sometime in the next ${data.developInHours} hours — ` +
`same link resolves once it's done.`
);
} else {
showResult(`share link: ${link}`);
}
} catch (err) {
showResult(err.message || 'something went wrong', true);
} finally {
btn.disabled = false;
}
}
fileInput.addEventListener('change', () => loadFile(fileInput.files[0]));
['dragover', 'dragenter'].forEach(evt =>
dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.add('drag-over');
})
);
['dragleave', 'dragend', 'drop'].forEach(evt =>
dropZone.addEventListener(evt, () => dropZone.classList.remove('drag-over'))
);
dropZone.addEventListener('drop', e => {
e.preventDefault();
loadFile(e.dataTransfer.files[0]);
});
[...presetRow.children].forEach(btn => {
btn.addEventListener('click', () => {
params.preset = btn.dataset.preset;
[...presetRow.children].forEach(b => b.classList.toggle('active', b === btn));
render();
});
});
grainSlider.addEventListener('input', () => { params.grain = Number(grainSlider.value); updateValueLabels(); render(); });
vignetteSlider.addEventListener('input', () => { params.vignette = Number(vignetteSlider.value); updateValueLabels(); render(); });
halationSlider.addEventListener('input', () => { params.halation = Number(halationSlider.value); updateValueLabels(); render(); });
leakSlider.addEventListener('input', () => { params.leak = Number(leakSlider.value); updateValueLabels(); render(); });
rerollLeakBtn.addEventListener('click', () => { params.leakSeed = Math.random(); render(); });
resetBtn.addEventListener('click', () => { resetParams(); render(); });
newPhotoBtn.addEventListener('click', () => {
originalImg = null;
fileInput.value = '';
editorCard.hidden = true;
dropCard.hidden = false;
resultPanel.hidden = true;
});
downloadBtn.addEventListener('click', async () => {
const blob = await canvasToBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `latent-${Date.now()}.jpg`;
a.click();
URL.revokeObjectURL(url);
});
shareBtn.addEventListener('click', () => upload(false));
developBtn.addEventListener('click', () => upload(true));
})();