Files
latent/app.js

633 lines
23 KiB
JavaScript
Raw Permalink Normal View History

(() => {
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 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 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');
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 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
// 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)' },
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);
const img = new Image();
img.onload = () => {
originalImg = img;
URL.revokeObjectURL(url);
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;
}
// 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;
const h = canvas.height;
workCtx.filter = PRESETS[params.preset].filter;
workCtx.globalCompositeOperation = 'source-over';
workCtx.clearRect(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);
}
// 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%)';
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();
}
// 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;
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();
}
// 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 alpha = params.leak / 100;
const hue = 25 + seed * 30; // warm amber/red range
workCtx.save();
workCtx.globalCompositeOperation = 'screen';
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();
}
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.
// 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;
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);
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&hellip;');
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: <a href="${link}">${link}</a><br>` +
`ready sometime in the next ${data.developInHours} hours &mdash; ` +
`same link resolves once it's done.`
);
} else {
showResult(`share link: <a href="${link}">${link}</a>`);
}
} 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]);
});
[...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;
[...presetRow.children].forEach(b => b.classList.toggle('active', b === btn));
render();
});
});
[...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(); });
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;
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));
})();