(() => { 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)); })();