diff --git a/app.js b/app.js index 2131e9d..c200728 100644 --- a/app.js +++ b/app.js @@ -6,12 +6,29 @@ const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); + const aspectRow = document.getElementById('aspect-row'); + const rotateLeftBtn = document.getElementById('rotate-left-btn'); + const rotateRightBtn = document.getElementById('rotate-right-btn'); + + const presetRow = document.getElementById('preset-row'); const grainSlider = document.getElementById('grain'); + const grainSizeSlider = document.getElementById('grain-size'); + const grainColorToggle = document.getElementById('grain-color-toggle'); const vignetteSlider = document.getElementById('vignette'); const halationSlider = document.getElementById('halation'); + const aberrationSlider = document.getElementById('aberration'); + const dustSlider = document.getElementById('dust'); + const rerollDustBtn = document.getElementById('reroll-dust'); const leakSlider = document.getElementById('leak'); + const leakShapeRow = document.getElementById('leak-shape-row'); const rerollLeakBtn = document.getElementById('reroll-leak'); - const presetRow = document.getElementById('preset-row'); + + const addExposureBtn = document.getElementById('add-exposure-btn'); + const removeExposureBtn = document.getElementById('remove-exposure-btn'); + const exposureInput = document.getElementById('exposure-input'); + const exposureControls = document.getElementById('exposure-controls'); + const exposureBlend = document.getElementById('exposure-blend'); + const exposureOpacity = document.getElementById('exposure-opacity'); const resetBtn = document.getElementById('reset-btn'); const newPhotoBtn = document.getElementById('new-photo-btn'); @@ -21,33 +38,70 @@ const resultPanel = document.getElementById('result-panel'); // Offscreen canvases reused across renders so we're not allocating per frame. + const source = document.createElement('canvas'); // original, rotated, uncropped + const sourceCtx = source.getContext('2d'); 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'); + const channelCanvas = document.createElement('canvas'); + const channelCtx = channelCanvas.getContext('2d'); let originalImg = null; + let exposureImg = null; const PRESETS = { - none: { filter: 'none' }, - // "pick a vibe" presets rather than raw white-balance sliders, per + 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)' }, + 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)' }, + polaroid: { filter: 'contrast(1.25) brightness(0.92) saturate(0.85) hue-rotate(6deg) sepia(0.18)' }, + infrared: { filter: 'hue-rotate(275deg) saturate(2.2) contrast(1.3) brightness(1.1)' }, + bw: { filter: 'grayscale(1) contrast(1.15) brightness(1.05)' }, + }; + + const ASPECTS = { + original: null, + square: 1, + portrait: 4 / 5, + landscape: 16 / 9, }; const params = { + rotation: 0, + cropAspect: 'original', preset: 'none', grain: 0, + grainSize: 40, + grainColor: 'mono', vignette: 0, halation: 0, + aberration: 0, + dust: 0, + dustSeed: Math.random(), leak: 0, + leakShape: 'bloom', leakSeed: Math.random(), + exposureBlend: 'screen', + exposureOpacity: 50, }; + // Small seeded PRNG so dust/scratch placement stays fixed across + // slider tweaks and only changes on an explicit reroll. + function mulberry32(seed) { + let a = Math.floor(seed * 0xffffffff); + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + function loadFile(file) { if (!file || !file.type.startsWith('image/')) return; const url = URL.createObjectURL(file); @@ -55,54 +109,133 @@ 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(); + rebuildSource(); render(); }; img.src = url; } function resetParams() { + params.rotation = 0; + params.cropAspect = 'original'; params.preset = 'none'; params.grain = 0; + params.grainSize = 40; + params.grainColor = 'mono'; params.vignette = 0; params.halation = 0; + params.aberration = 0; + params.dust = 0; + params.dustSeed = Math.random(); params.leak = 0; + params.leakShape = 'bloom'; params.leakSeed = Math.random(); + exposureImg = null; + params.exposureBlend = 'screen'; + params.exposureOpacity = 50; + grainSlider.value = 0; + grainSizeSlider.value = 40; + grainColorToggle.dataset.mode = 'mono'; + grainColorToggle.textContent = 'grain: mono'; vignetteSlider.value = 0; halationSlider.value = 0; + aberrationSlider.value = 0; + dustSlider.value = 0; leakSlider.value = 0; + exposureBlend.value = 'screen'; + exposureOpacity.value = 50; + exposureControls.hidden = true; + addExposureBtn.hidden = false; + removeExposureBtn.hidden = true; + exposureInput.value = ''; + updateValueLabels(); [...presetRow.children].forEach(b => b.classList.toggle('active', b.dataset.preset === 'none')); + [...aspectRow.children].forEach(b => b.classList.toggle('active', b.dataset.aspect === 'original')); + [...leakShapeRow.children].forEach(b => b.classList.toggle('active', b.dataset.shape === 'bloom')); } 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('aberration-value').textContent = params.aberration; + document.getElementById('dust-value').textContent = params.dust; 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. + // Rebuilds `source` (the rotated + cropped, but otherwise unedited, + // base image) from originalImg. Only called when rotation, crop, or + // the loaded image change — not on every effect-slider tweak — since + // it's the one step that can't be recomputed from other canvases. + function rebuildSource() { + if (!originalImg) return; + const rot = params.rotation; + const iw = originalImg.naturalWidth; + const ih = originalImg.naturalHeight; + const swapped = rot === 90 || rot === 270; + const rotatedW = swapped ? ih : iw; + const rotatedH = swapped ? iw : ih; + + const rotated = document.createElement('canvas'); + rotated.width = rotatedW; + rotated.height = rotatedH; + const rc = rotated.getContext('2d'); + rc.save(); + rc.translate(rotatedW / 2, rotatedH / 2); + rc.rotate((rot * Math.PI) / 180); + rc.drawImage(originalImg, -iw / 2, -ih / 2); + rc.restore(); + + const targetAspect = ASPECTS[params.cropAspect]; + let cropW = rotatedW; + let cropH = rotatedH; + let cropX = 0; + let cropY = 0; + if (targetAspect) { + const currentAspect = rotatedW / rotatedH; + if (currentAspect > targetAspect) { + cropW = Math.round(rotatedH * targetAspect); + cropX = Math.round((rotatedW - cropW) / 2); + } else { + cropH = Math.round(rotatedW / targetAspect); + cropY = Math.round((rotatedH - cropH) / 2); + } + } + + // Cap so a raw phone photo doesn't choke canvas ops. + const maxDim = 2400; + let outW = cropW; + let outH = cropH; + if (Math.max(outW, outH) > maxDim) { + const scale = maxDim / Math.max(outW, outH); + outW = Math.round(outW * scale); + outH = Math.round(outH * scale); + } + + source.width = outW; + source.height = outH; + sourceCtx.clearRect(0, 0, outW, outH); + sourceCtx.drawImage(rotated, cropX, cropY, cropW, cropH, 0, 0, outW, outH); + + canvas.width = outW; + canvas.height = outH; + work.width = outW; + work.height = outH; + bloom.width = outW; + bloom.height = outH; + channelCanvas.width = outW; + channelCanvas.height = outH; + } + + // Recomputes the full stack from `source` 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; @@ -111,20 +244,41 @@ workCtx.filter = PRESETS[params.preset].filter; workCtx.globalCompositeOperation = 'source-over'; workCtx.clearRect(0, 0, w, h); - workCtx.drawImage(originalImg, 0, 0, w, h); + workCtx.drawImage(source, 0, 0); workCtx.filter = 'none'; + if (exposureImg) applyDoubleExposure(w, h); if (params.halation > 0) applyHalation(w, h); + if (params.aberration > 0) applyChromaticAberration(w, h); if (params.vignette > 0) applyVignette(w, h); if (params.leak > 0) applyLightLeak(w, h); + if (params.dust > 0) applyDustScratches(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. + // Blends a second uploaded photo in, cover-fit to the canvas — the + // literal "double exposure" film trick. + function applyDoubleExposure(w, h) { + const iw = exposureImg.naturalWidth; + const ih = exposureImg.naturalHeight; + const scale = Math.max(w / iw, h / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = (w - dw) / 2; + const dy = (h - dh) / 2; + + workCtx.save(); + workCtx.globalCompositeOperation = params.exposureBlend; + workCtx.globalAlpha = params.exposureOpacity / 100; + workCtx.drawImage(exposureImg, dx, dy, dw, dh); + workCtx.restore(); + } + + // 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%)'; @@ -143,6 +297,41 @@ workCtx.restore(); } + // Isolates a single color channel by multiplying against a solid + // pure-channel fill — cheap alternative to a per-pixel channel split. + function channelLayer(colorRgb) { + channelCtx.clearRect(0, 0, channelCanvas.width, channelCanvas.height); + channelCtx.globalCompositeOperation = 'source-over'; + channelCtx.drawImage(work, 0, 0); + channelCtx.globalCompositeOperation = 'multiply'; + channelCtx.fillStyle = colorRgb; + channelCtx.fillRect(0, 0, channelCanvas.width, channelCanvas.height); + return channelCanvas; + } + + // Cheap "lens fringing" look: shift isolated red/blue channel layers in + // opposite directions and screen them back over the full-color image. + // Not a physically accurate simulation, just a stylized fringe. + function applyChromaticAberration(w, h) { + const amount = params.aberration / 100; + const shift = Math.max(1, Math.round(amount * Math.max(w, h) * 0.015)); + + const redSnapshot = document.createElement('canvas'); + redSnapshot.width = w; redSnapshot.height = h; + redSnapshot.getContext('2d').drawImage(channelLayer('rgb(255,0,0)'), 0, 0); + + const blueSnapshot = document.createElement('canvas'); + blueSnapshot.width = w; blueSnapshot.height = h; + blueSnapshot.getContext('2d').drawImage(channelLayer('rgb(0,0,255)'), 0, 0); + + workCtx.save(); + workCtx.globalCompositeOperation = 'screen'; + workCtx.globalAlpha = 0.55; + workCtx.drawImage(redSnapshot, -shift, 0); + workCtx.drawImage(blueSnapshot, shift, 0); + workCtx.restore(); + } + function applyVignette(w, h) { const amount = params.vignette / 100; const cx = w / 2; @@ -158,26 +347,87 @@ 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. + // Three shapes off one seed so repeat use doesn't look identical, and + // no pre-baked PNG assets are needed — it's all procedural gradients. 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 alpha = params.leak / 100; 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); + + if (params.leakShape === 'streak') { + const angle = seed * Math.PI * 2; + const cx = w * (0.2 + seed * 0.6); + const cy = h * (0.2 + (1 - seed) * 0.6); + const length = Math.max(w, h) * 1.4; + const x0 = cx - Math.cos(angle) * length / 2; + const y0 = cy - Math.sin(angle) * length / 2; + const x1 = cx + Math.cos(angle) * length / 2; + const y1 = cy + Math.sin(angle) * length / 2; + const grad = workCtx.createLinearGradient(x0, y0, x1, y1); + grad.addColorStop(0, 'hsla(0, 0%, 0%, 0)'); + grad.addColorStop(0.5, `hsla(${hue}, 90%, 60%, ${alpha * 0.75})`); + grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)'); + workCtx.fillStyle = grad; + workCtx.fillRect(0, 0, w, h); + } else if (params.leakShape === 'corner') { + const corners = [[0, 0], [w, 0], [0, h], [w, h]]; + const [cx, cy] = corners[Math.floor(seed * 4) % 4]; + const radius = Math.max(w, h) * (0.55 + seed * 0.3); + const grad = workCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); + grad.addColorStop(0, `hsla(${hue}, 90%, 60%, ${alpha * 0.9})`); + grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)'); + workCtx.fillStyle = grad; + workCtx.fillRect(0, 0, w, h); + } else { // bloom — a blob placed near a randomized edge point + 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); + grad.addColorStop(0, `hsla(${hue}, 90%, 60%, ${alpha * 0.9})`); + grad.addColorStop(0.5, `hsla(${hue + 10}, 90%, 55%, ${alpha * 0.35})`); + grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)'); + workCtx.fillStyle = grad; + workCtx.fillRect(0, 0, w, h); + } + + workCtx.restore(); + } + + // Random hairline scratches + specks of dust, regenerated only on + // reroll (seeded), not on every slider tweak. + function applyDustScratches(w, h) { + const amount = params.dust / 100; + const rng = mulberry32(params.dustSeed); + + workCtx.save(); + workCtx.globalCompositeOperation = 'screen'; + + const scratchCount = Math.round(amount * 12); + for (let i = 0; i < scratchCount; i++) { + workCtx.strokeStyle = `rgba(255,255,255,${0.05 + rng() * 0.15})`; + workCtx.lineWidth = 1; + const x = rng() * w; + workCtx.beginPath(); + workCtx.moveTo(x, 0); + workCtx.lineTo(x + (rng() - 0.5) * 24, h); + workCtx.stroke(); + } + + const dustCount = Math.round(amount * 90); + for (let i = 0; i < dustCount; i++) { + workCtx.fillStyle = `rgba(255,255,255,${0.2 + rng() * 0.5})`; + const x = rng() * w; + const y = rng() * h; + const r = 0.5 + rng() * 1.5; + workCtx.beginPath(); + workCtx.arc(x, y, r, 0, Math.PI * 2); + workCtx.fill(); + } + workCtx.restore(); } @@ -185,18 +435,29 @@ // 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)); + // grainSize maps a bigger divisor to coarser, more visible grain. + const divisor = 1.5 + (params.grainSize / 100) * 8.5; + const nw = Math.max(1, Math.round(w / divisor)); + const nh = Math.max(1, Math.round(h / divisor)); 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; + if (params.grainColor === 'color') { + for (let i = 0; i < d.length; i += 4) { + d[i] = Math.random() * 255; + d[i + 1] = Math.random() * 255; + d[i + 2] = Math.random() * 255; + d[i + 3] = 255; + } + } else { + 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); @@ -267,6 +528,26 @@ loadFile(e.dataTransfer.files[0]); }); + [...aspectRow.children].forEach(btn => { + btn.addEventListener('click', () => { + params.cropAspect = btn.dataset.aspect; + [...aspectRow.children].forEach(b => b.classList.toggle('active', b === btn)); + rebuildSource(); + render(); + }); + }); + + rotateLeftBtn.addEventListener('click', () => { + params.rotation = (params.rotation + 270) % 360; + rebuildSource(); + render(); + }); + rotateRightBtn.addEventListener('click', () => { + params.rotation = (params.rotation + 90) % 360; + rebuildSource(); + render(); + }); + [...presetRow.children].forEach(btn => { btn.addEventListener('click', () => { params.preset = btn.dataset.preset; @@ -275,15 +556,61 @@ }); }); + [...leakShapeRow.children].forEach(btn => { + btn.addEventListener('click', () => { + params.leakShape = btn.dataset.shape; + [...leakShapeRow.children].forEach(b => b.classList.toggle('active', b === btn)); + render(); + }); + }); + grainSlider.addEventListener('input', () => { params.grain = Number(grainSlider.value); updateValueLabels(); render(); }); + grainSizeSlider.addEventListener('input', () => { params.grainSize = Number(grainSizeSlider.value); render(); }); + grainColorToggle.addEventListener('click', () => { + params.grainColor = params.grainColor === 'mono' ? 'color' : 'mono'; + grainColorToggle.dataset.mode = params.grainColor; + grainColorToggle.textContent = `grain: ${params.grainColor}`; + render(); + }); vignetteSlider.addEventListener('input', () => { params.vignette = Number(vignetteSlider.value); updateValueLabels(); render(); }); halationSlider.addEventListener('input', () => { params.halation = Number(halationSlider.value); updateValueLabels(); render(); }); + aberrationSlider.addEventListener('input', () => { params.aberration = Number(aberrationSlider.value); updateValueLabels(); render(); }); + dustSlider.addEventListener('input', () => { params.dust = Number(dustSlider.value); updateValueLabels(); render(); }); + rerollDustBtn.addEventListener('click', () => { params.dustSeed = Math.random(); render(); }); leakSlider.addEventListener('input', () => { params.leak = Number(leakSlider.value); updateValueLabels(); render(); }); rerollLeakBtn.addEventListener('click', () => { params.leakSeed = Math.random(); render(); }); - resetBtn.addEventListener('click', () => { resetParams(); render(); }); + addExposureBtn.addEventListener('click', () => exposureInput.click()); + exposureInput.addEventListener('change', () => { + const file = exposureInput.files[0]; + if (!file || !file.type.startsWith('image/')) return; + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + exposureImg = img; + URL.revokeObjectURL(url); + addExposureBtn.hidden = true; + removeExposureBtn.hidden = false; + exposureControls.hidden = false; + render(); + }; + img.src = url; + }); + removeExposureBtn.addEventListener('click', () => { + exposureImg = null; + exposureInput.value = ''; + addExposureBtn.hidden = false; + removeExposureBtn.hidden = true; + exposureControls.hidden = true; + render(); + }); + exposureBlend.addEventListener('change', () => { params.exposureBlend = exposureBlend.value; render(); }); + exposureOpacity.addEventListener('input', () => { params.exposureOpacity = Number(exposureOpacity.value); render(); }); + + resetBtn.addEventListener('click', () => { resetParams(); rebuildSource(); render(); }); newPhotoBtn.addEventListener('click', () => { originalImg = null; + exposureImg = null; fileInput.value = ''; editorCard.hidden = true; dropCard.hidden = false; diff --git a/index.html b/index.html index 2405cee..ac93cca 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,20 @@