Add crop/rotate, more presets, and several new film effects
All checks were successful
Docker / build-and-push (push) Successful in 2m58s
All checks were successful
Docker / build-and-push (push) Successful in 2m58s
Chromatic aberration, dust & scratches, light-leak shape variety (bloom/streak/corner), double exposure, and grain size/color controls, plus polaroid/infrared/black & white presets and non-destructive crop+rotate ahead of the existing effect stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
425
app.js
425
app.js
@@ -6,12 +6,29 @@
|
|||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
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 grainSlider = document.getElementById('grain');
|
||||||
|
const grainSizeSlider = document.getElementById('grain-size');
|
||||||
|
const grainColorToggle = document.getElementById('grain-color-toggle');
|
||||||
const vignetteSlider = document.getElementById('vignette');
|
const vignetteSlider = document.getElementById('vignette');
|
||||||
const halationSlider = document.getElementById('halation');
|
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 leakSlider = document.getElementById('leak');
|
||||||
|
const leakShapeRow = document.getElementById('leak-shape-row');
|
||||||
const rerollLeakBtn = document.getElementById('reroll-leak');
|
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 resetBtn = document.getElementById('reset-btn');
|
||||||
const newPhotoBtn = document.getElementById('new-photo-btn');
|
const newPhotoBtn = document.getElementById('new-photo-btn');
|
||||||
@@ -21,33 +38,70 @@
|
|||||||
const resultPanel = document.getElementById('result-panel');
|
const resultPanel = document.getElementById('result-panel');
|
||||||
|
|
||||||
// Offscreen canvases reused across renders so we're not allocating per frame.
|
// 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 work = document.createElement('canvas');
|
||||||
const workCtx = work.getContext('2d');
|
const workCtx = work.getContext('2d');
|
||||||
const bloom = document.createElement('canvas');
|
const bloom = document.createElement('canvas');
|
||||||
const bloomCtx = bloom.getContext('2d');
|
const bloomCtx = bloom.getContext('2d');
|
||||||
const noiseCanvas = document.createElement('canvas');
|
const noiseCanvas = document.createElement('canvas');
|
||||||
const noiseCtx = noiseCanvas.getContext('2d');
|
const noiseCtx = noiseCanvas.getContext('2d');
|
||||||
|
const channelCanvas = document.createElement('canvas');
|
||||||
|
const channelCtx = channelCanvas.getContext('2d');
|
||||||
|
|
||||||
let originalImg = null;
|
let originalImg = null;
|
||||||
|
let exposureImg = null;
|
||||||
|
|
||||||
const PRESETS = {
|
const PRESETS = {
|
||||||
none: { filter: 'none' },
|
none: { filter: 'none' },
|
||||||
// "pick a vibe" presets rather than raw white-balance sliders, per
|
// "Pick a vibe" presets rather than raw white-balance sliders, per
|
||||||
// PROPOSAL.md — expressed as CSS filter strings applied at draw time.
|
// 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)' },
|
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)' },
|
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)' },
|
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 = {
|
const params = {
|
||||||
|
rotation: 0,
|
||||||
|
cropAspect: 'original',
|
||||||
preset: 'none',
|
preset: 'none',
|
||||||
grain: 0,
|
grain: 0,
|
||||||
|
grainSize: 40,
|
||||||
|
grainColor: 'mono',
|
||||||
vignette: 0,
|
vignette: 0,
|
||||||
halation: 0,
|
halation: 0,
|
||||||
|
aberration: 0,
|
||||||
|
dust: 0,
|
||||||
|
dustSeed: Math.random(),
|
||||||
leak: 0,
|
leak: 0,
|
||||||
|
leakShape: 'bloom',
|
||||||
leakSeed: Math.random(),
|
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) {
|
function loadFile(file) {
|
||||||
if (!file || !file.type.startsWith('image/')) return;
|
if (!file || !file.type.startsWith('image/')) return;
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
@@ -55,54 +109,133 @@
|
|||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
originalImg = img;
|
originalImg = img;
|
||||||
URL.revokeObjectURL(url);
|
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;
|
dropCard.hidden = true;
|
||||||
editorCard.hidden = false;
|
editorCard.hidden = false;
|
||||||
resultPanel.hidden = true;
|
resultPanel.hidden = true;
|
||||||
resetParams();
|
resetParams();
|
||||||
|
rebuildSource();
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetParams() {
|
function resetParams() {
|
||||||
|
params.rotation = 0;
|
||||||
|
params.cropAspect = 'original';
|
||||||
params.preset = 'none';
|
params.preset = 'none';
|
||||||
params.grain = 0;
|
params.grain = 0;
|
||||||
|
params.grainSize = 40;
|
||||||
|
params.grainColor = 'mono';
|
||||||
params.vignette = 0;
|
params.vignette = 0;
|
||||||
params.halation = 0;
|
params.halation = 0;
|
||||||
|
params.aberration = 0;
|
||||||
|
params.dust = 0;
|
||||||
|
params.dustSeed = Math.random();
|
||||||
params.leak = 0;
|
params.leak = 0;
|
||||||
|
params.leakShape = 'bloom';
|
||||||
params.leakSeed = Math.random();
|
params.leakSeed = Math.random();
|
||||||
|
exposureImg = null;
|
||||||
|
params.exposureBlend = 'screen';
|
||||||
|
params.exposureOpacity = 50;
|
||||||
|
|
||||||
grainSlider.value = 0;
|
grainSlider.value = 0;
|
||||||
|
grainSizeSlider.value = 40;
|
||||||
|
grainColorToggle.dataset.mode = 'mono';
|
||||||
|
grainColorToggle.textContent = 'grain: mono';
|
||||||
vignetteSlider.value = 0;
|
vignetteSlider.value = 0;
|
||||||
halationSlider.value = 0;
|
halationSlider.value = 0;
|
||||||
|
aberrationSlider.value = 0;
|
||||||
|
dustSlider.value = 0;
|
||||||
leakSlider.value = 0;
|
leakSlider.value = 0;
|
||||||
|
exposureBlend.value = 'screen';
|
||||||
|
exposureOpacity.value = 50;
|
||||||
|
exposureControls.hidden = true;
|
||||||
|
addExposureBtn.hidden = false;
|
||||||
|
removeExposureBtn.hidden = true;
|
||||||
|
exposureInput.value = '';
|
||||||
|
|
||||||
updateValueLabels();
|
updateValueLabels();
|
||||||
[...presetRow.children].forEach(b => b.classList.toggle('active', b.dataset.preset === 'none'));
|
[...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() {
|
function updateValueLabels() {
|
||||||
document.getElementById('grain-value').textContent = params.grain;
|
document.getElementById('grain-value').textContent = params.grain;
|
||||||
document.getElementById('vignette-value').textContent = params.vignette;
|
document.getElementById('vignette-value').textContent = params.vignette;
|
||||||
document.getElementById('halation-value').textContent = params.halation;
|
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;
|
document.getElementById('leak-value').textContent = params.leak;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recomputes the full stack from the original image every time — never
|
// Rebuilds `source` (the rotated + cropped, but otherwise unedited,
|
||||||
// chains operations onto already-processed pixels — so dragging any
|
// base image) from originalImg. Only called when rotation, crop, or
|
||||||
// slider back to 0 reproduces the source exactly.
|
// 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() {
|
function render() {
|
||||||
if (!originalImg) return;
|
if (!originalImg) return;
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
@@ -111,20 +244,41 @@
|
|||||||
workCtx.filter = PRESETS[params.preset].filter;
|
workCtx.filter = PRESETS[params.preset].filter;
|
||||||
workCtx.globalCompositeOperation = 'source-over';
|
workCtx.globalCompositeOperation = 'source-over';
|
||||||
workCtx.clearRect(0, 0, w, h);
|
workCtx.clearRect(0, 0, w, h);
|
||||||
workCtx.drawImage(originalImg, 0, 0, w, h);
|
workCtx.drawImage(source, 0, 0);
|
||||||
workCtx.filter = 'none';
|
workCtx.filter = 'none';
|
||||||
|
|
||||||
|
if (exposureImg) applyDoubleExposure(w, h);
|
||||||
if (params.halation > 0) applyHalation(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.vignette > 0) applyVignette(w, h);
|
||||||
if (params.leak > 0) applyLightLeak(w, h);
|
if (params.leak > 0) applyLightLeak(w, h);
|
||||||
|
if (params.dust > 0) applyDustScratches(w, h);
|
||||||
if (params.grain > 0) applyGrain(w, h);
|
if (params.grain > 0) applyGrain(w, h);
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.drawImage(work, 0, 0);
|
ctx.drawImage(work, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bloom around bright areas: blur a thresholded bright-pass and screen it
|
// Blends a second uploaded photo in, cover-fit to the canvas — the
|
||||||
// back over the original.
|
// 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) {
|
function applyHalation(w, h) {
|
||||||
bloomCtx.clearRect(0, 0, w, h);
|
bloomCtx.clearRect(0, 0, w, h);
|
||||||
bloomCtx.filter = 'brightness(180%) contrast(400%) saturate(120%)';
|
bloomCtx.filter = 'brightness(180%) contrast(400%) saturate(120%)';
|
||||||
@@ -143,6 +297,41 @@
|
|||||||
workCtx.restore();
|
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) {
|
function applyVignette(w, h) {
|
||||||
const amount = params.vignette / 100;
|
const amount = params.vignette / 100;
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
@@ -158,26 +347,87 @@
|
|||||||
workCtx.restore();
|
workCtx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warm streak/bloom near a randomized edge point, rotated per seed so
|
// Three shapes off one seed so repeat use doesn't look identical, and
|
||||||
// repeat use doesn't look identical — no pre-baked PNG assets needed,
|
// no pre-baked PNG assets are needed — it's all procedural gradients.
|
||||||
// it's a procedural gradient.
|
|
||||||
function applyLightLeak(w, h) {
|
function applyLightLeak(w, h) {
|
||||||
const seed = params.leakSeed;
|
const seed = params.leakSeed;
|
||||||
const angle = seed * Math.PI * 2;
|
const alpha = params.leak / 100;
|
||||||
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
|
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.save();
|
||||||
workCtx.globalCompositeOperation = 'screen';
|
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();
|
workCtx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,18 +435,29 @@
|
|||||||
// Noise is generated at reduced resolution and scaled up — a
|
// Noise is generated at reduced resolution and scaled up — a
|
||||||
// per-pixel random overlay at full phone-photo resolution is
|
// per-pixel random overlay at full phone-photo resolution is
|
||||||
// needlessly slow and reads no differently once blended in.
|
// needlessly slow and reads no differently once blended in.
|
||||||
const nw = Math.max(1, Math.round(w / 3));
|
// grainSize maps a bigger divisor to coarser, more visible grain.
|
||||||
const nh = Math.max(1, Math.round(h / 3));
|
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.width = nw;
|
||||||
noiseCanvas.height = nh;
|
noiseCanvas.height = nh;
|
||||||
const imgData = noiseCtx.createImageData(nw, nh);
|
const imgData = noiseCtx.createImageData(nw, nh);
|
||||||
const d = imgData.data;
|
const d = imgData.data;
|
||||||
for (let i = 0; i < d.length; i += 4) {
|
if (params.grainColor === 'color') {
|
||||||
const v = Math.random() * 255;
|
for (let i = 0; i < d.length; i += 4) {
|
||||||
d[i] = v;
|
d[i] = Math.random() * 255;
|
||||||
d[i + 1] = v;
|
d[i + 1] = Math.random() * 255;
|
||||||
d[i + 2] = v;
|
d[i + 2] = Math.random() * 255;
|
||||||
d[i + 3] = 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);
|
noiseCtx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
@@ -267,6 +528,26 @@
|
|||||||
loadFile(e.dataTransfer.files[0]);
|
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 => {
|
[...presetRow.children].forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
params.preset = btn.dataset.preset;
|
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(); });
|
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(); });
|
vignetteSlider.addEventListener('input', () => { params.vignette = Number(vignetteSlider.value); updateValueLabels(); render(); });
|
||||||
halationSlider.addEventListener('input', () => { params.halation = Number(halationSlider.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(); });
|
leakSlider.addEventListener('input', () => { params.leak = Number(leakSlider.value); updateValueLabels(); render(); });
|
||||||
rerollLeakBtn.addEventListener('click', () => { params.leakSeed = Math.random(); 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', () => {
|
newPhotoBtn.addEventListener('click', () => {
|
||||||
originalImg = null;
|
originalImg = null;
|
||||||
|
exposureImg = null;
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
editorCard.hidden = true;
|
editorCard.hidden = true;
|
||||||
dropCard.hidden = false;
|
dropCard.hidden = false;
|
||||||
|
|||||||
69
index.html
69
index.html
@@ -28,6 +28,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">crop & rotate</div>
|
||||||
|
<div class="preset-row" id="aspect-row">
|
||||||
|
<button class="preset-btn active" data-aspect="original">original</button>
|
||||||
|
<button class="preset-btn" data-aspect="square">square</button>
|
||||||
|
<button class="preset-btn" data-aspect="portrait">4:5</button>
|
||||||
|
<button class="preset-btn" data-aspect="landscape">16:9</button>
|
||||||
|
</div>
|
||||||
|
<div class="inline-row">
|
||||||
|
<button class="btn btn-small" id="rotate-left-btn">rotate ⟲</button>
|
||||||
|
<button class="btn btn-small" id="rotate-right-btn">rotate ⟳</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<div class="control-label">vibe</div>
|
<div class="control-label">vibe</div>
|
||||||
<div class="preset-row" id="preset-row">
|
<div class="preset-row" id="preset-row">
|
||||||
@@ -35,6 +49,9 @@
|
|||||||
<button class="preset-btn" data-preset="warm">expired warm</button>
|
<button class="preset-btn" data-preset="warm">expired warm</button>
|
||||||
<button class="preset-btn" data-preset="cool">cross-processed</button>
|
<button class="preset-btn" data-preset="cool">cross-processed</button>
|
||||||
<button class="preset-btn" data-preset="faded">faded</button>
|
<button class="preset-btn" data-preset="faded">faded</button>
|
||||||
|
<button class="preset-btn" data-preset="polaroid">polaroid</button>
|
||||||
|
<button class="preset-btn" data-preset="infrared">infrared</button>
|
||||||
|
<button class="preset-btn" data-preset="bw">black & white</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,6 +61,10 @@
|
|||||||
<span class="control-value" id="grain-value">0</span>
|
<span class="control-value" id="grain-value">0</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="grain" min="0" max="100" value="0" class="slider">
|
<input type="range" id="grain" min="0" max="100" value="0" class="slider">
|
||||||
|
<div class="inline-row">
|
||||||
|
<label class="mini-label">size <input type="range" id="grain-size" min="0" max="100" value="40" class="slider slider-small"></label>
|
||||||
|
<button class="btn btn-small" id="grain-color-toggle" data-mode="mono">grain: mono</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@@ -62,13 +83,59 @@
|
|||||||
<input type="range" id="halation" min="0" max="100" value="0" class="slider">
|
<input type="range" id="halation" min="0" max="100" value="0" class="slider">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">
|
||||||
|
<span>chromatic aberration</span>
|
||||||
|
<span class="control-value" id="aberration-value">0</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="aberration" min="0" max="100" value="0" class="slider">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">
|
||||||
|
<span>dust & scratches</span>
|
||||||
|
<span class="control-value" id="dust-value">0</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="dust" min="0" max="100" value="0" class="slider">
|
||||||
|
<button class="btn btn-small" id="reroll-dust">reroll dust</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<div class="control-label">
|
<div class="control-label">
|
||||||
<span>light leak</span>
|
<span>light leak</span>
|
||||||
<span class="control-value" id="leak-value">0</span>
|
<span class="control-value" id="leak-value">0</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="leak" min="0" max="100" value="0" class="slider">
|
<input type="range" id="leak" min="0" max="100" value="0" class="slider">
|
||||||
<button class="btn btn-small" id="reroll-leak">reroll placement</button>
|
<div class="inline-row">
|
||||||
|
<div class="preset-row" id="leak-shape-row">
|
||||||
|
<button class="preset-btn btn-small active" data-shape="bloom">bloom</button>
|
||||||
|
<button class="preset-btn btn-small" data-shape="streak">streak</button>
|
||||||
|
<button class="preset-btn btn-small" data-shape="corner">corner</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-small" id="reroll-leak">reroll placement</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">double exposure</div>
|
||||||
|
<div class="inline-row">
|
||||||
|
<button class="btn btn-small" id="add-exposure-btn">add second photo</button>
|
||||||
|
<button class="btn btn-small" id="remove-exposure-btn" hidden>remove</button>
|
||||||
|
<input type="file" id="exposure-input" accept="image/*" hidden>
|
||||||
|
</div>
|
||||||
|
<div id="exposure-controls" hidden>
|
||||||
|
<div class="inline-row">
|
||||||
|
<label class="mini-label">blend
|
||||||
|
<select id="exposure-blend" class="select-small">
|
||||||
|
<option value="screen">screen</option>
|
||||||
|
<option value="lighten">lighten</option>
|
||||||
|
<option value="overlay">overlay</option>
|
||||||
|
<option value="multiply">multiply</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="exposure-opacity" min="0" max="100" value="50" class="slider">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
43
style.css
43
style.css
@@ -214,6 +214,49 @@ p { margin: 0; }
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inline rows / minor controls ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.inline-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-small {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-small {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Author CSS always wins over the UA stylesheet's [hidden]{display:none},
|
||||||
|
so an unconditional `display: flex` here would keep these controls
|
||||||
|
visible even while `hidden` is set on the element. */
|
||||||
|
#exposure-controls:not([hidden]) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Actions / buttons ──────────────────────────────────────────────── */
|
/* ── Actions / buttons ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.actions-row {
|
.actions-row {
|
||||||
|
|||||||
Reference in New Issue
Block a user