Initial implementation of latent: film-effects editor + develop-later server
All checks were successful
Docker / build-and-push (push) Successful in 3m29s
All checks were successful
Docker / build-and-push (push) Successful in 3m29s
Canvas-based client-side editor (grain, vignette, halation, procedural light leaks, color-cast presets) plus a small Express/SQLite server for the two paths that need persistence: instant share links and delayed "send to develop" links with a randomized reveal time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
305
app.js
Normal file
305
app.js
Normal file
@@ -0,0 +1,305 @@
|
||||
(() => {
|
||||
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: <a href="${link}">${link}</a><br>` +
|
||||
`ready sometime in the next ${data.developInHours} hours — ` +
|
||||
`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]);
|
||||
});
|
||||
|
||||
[...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));
|
||||
})();
|
||||
Reference in New Issue
Block a user