GhostYou / index.html
Aluode's picture
Upload 2 files
e438cc9 verified
Raw History Blame Contribute Delete
8.01 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ghost You</title>
<style>
:root { --bg:#07060b; --fg:#d9e4ff; --dim:#7d86a8; --glow:#9fd8ff; --accent:#ff8a3d; }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:var(--bg); color:var(--fg);
font:15px/1.45 "Georgia", serif; overflow:hidden; }
#stage { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; }
canvas { width:min(100vw, 133.33vh); height:min(75vw, 100vh); image-rendering:auto;
filter:contrast(1.08) saturate(.85); }
#veil { position:fixed; inset:0; pointer-events:none;
background:radial-gradient(ellipse at center, transparent 45%, rgba(4,2,10,.85) 100%); }
#intro { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:18px; text-align:center; padding:16px;
background:radial-gradient(ellipse at center, #15112a 0%, var(--bg) 70%); z-index:5; }
h1 { font-size:clamp(40px, 9vw, 92px); margin:0; letter-spacing:.04em; font-weight:normal;
color:var(--fg); text-shadow:0 0 18px var(--glow), 0 0 42px #4a6cff; animation:float 4s ease-in-out infinite; }
@keyframes float { 50% { transform:translateY(-10px); opacity:.85; } }
p { max-width:34em; color:var(--dim); margin:0; }
button { font:inherit; color:var(--bg); background:var(--accent); border:0; border-radius:999px;
padding:12px 26px; cursor:pointer; box-shadow:0 0 24px rgba(255,138,61,.45); }
button.ghost { background:transparent; color:var(--fg); border:1px solid #3a3f5c; box-shadow:none; padding:8px 16px; }
#bar { position:fixed; left:0; right:0; bottom:0; display:flex; flex-wrap:wrap; gap:8px; align-items:center;
justify-content:center; padding:10px 16px; z-index:4; font-size:13px; color:var(--dim);
background:linear-gradient(transparent, rgba(7,6,11,.9)); }
#bar label { display:flex; gap:6px; align-items:center; }
input[type=range] { width:110px; accent-color:var(--glow); }
#err { color:var(--accent); }
</style>
</head>
<body>
<div id="stage"><canvas id="c" width="320" height="240"></canvas></div>
<div id="veil"></div>
<div id="intro">
<h1>Ghost You</h1>
<p>Sit still and you are solid. Move, and you fade until the room shows through you.
Your motion plays a theremin: higher on the screen is a higher note, more motion is louder.</p>
<p>For the best haunting, step out of view for a few seconds so the room can learn what is behind you.</p>
<button id="go">Summon the ghost</button>
<span id="err"></span>
</div>
<div id="bar" hidden>
<button class="ghost" id="learn">Learn empty room (3 s)</button>
<label>ghostliness <input id="ghostly" type="range" min="0" max="100" value="85"></label>
<label>linger <input id="linger" type="range" min="0" max="98" value="88"></label>
<label>volume <input id="vol" type="range" min="0" max="100" value="55"></label>
<button class="ghost" id="mute">mute</button>
</div>
<video id="v" playsinline muted hidden></video>
<script>
const W = 320, H = 240, N = W * H;
const cv = document.getElementById('c'), ctx = cv.getContext('2d', { willReadFrequently:true });
const video = document.getElementById('v');
const bg = new Float32Array(N * 3); // the room's memory: slow average of what is behind you
const trail = new Float32Array(N); // lingering ghost mask
let haveBg = false, learnUntil = 0, audio = null, muted = false;
const $ = id => document.getElementById(id);
$('go').onclick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video:{ width:640, height:480, facingMode:'user' }, audio:false });
video.srcObject = stream; await video.play();
audio = makeTheremin();
$('intro').remove(); $('bar').hidden = false;
requestAnimationFrame(loop);
} catch (e) { $('err').textContent = 'The camera would not open: ' + (e.message || e); }
};
$('learn').onclick = () => { learnUntil = performance.now() + 3000; };
$('mute').onclick = () => { muted = !muted; $('mute').textContent = muted ? 'unmute' : 'mute'; };
function makeTheremin() {
const ac = new (window.AudioContext || window.webkitAudioContext)();
const out = ac.createGain(); out.gain.value = 0;
const osc = ac.createOscillator(); osc.type = 'sine'; osc.frequency.value = 440;
const osc2 = ac.createOscillator(); osc2.type = 'sine'; osc2.frequency.value = 441.5;
const g2 = ac.createGain(); g2.gain.value = .35;
const lfo = ac.createOscillator(); lfo.frequency.value = 5.6;
const lfoAmt = ac.createGain(); lfoAmt.gain.value = 6;
lfo.connect(lfoAmt); lfoAmt.connect(osc.frequency); lfoAmt.connect(osc2.frequency);
// a little haunted-hallway echo
const delay = ac.createDelay(1); delay.delayTime.value = .33;
const fb = ac.createGain(); fb.gain.value = .38;
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 1800;
osc.connect(out); osc2.connect(g2); g2.connect(out);
out.connect(ac.destination); out.connect(delay); delay.connect(lp); lp.connect(fb); fb.connect(delay); lp.connect(ac.destination);
osc.start(); osc2.start(); lfo.start();
return { ac, out, osc, osc2, lfoAmt };
}
let energy = 0, cy = .5, cx = .5;
function loop(t) {
// mirror the camera so it behaves like a mirror
ctx.save(); ctx.translate(W, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, W, H); ctx.restore();
const img = ctx.getImageData(0, 0, W, H), d = img.data;
const learning = t < learnUntil;
if (!haveBg) { for (let i = 0; i < N; i++) { bg[3*i] = d[4*i]; bg[3*i+1] = d[4*i+1]; bg[3*i+2] = d[4*i+2]; } haveBg = true; }
const ghostly = $('ghostly').value / 100, linger = $('linger').value / 100;
let mSum = 0, ySum = 0, xSum = 0;
for (let i = 0; i < N; i++) {
const r = d[4*i], g = d[4*i+1], b = d[4*i+2];
const br = bg[3*i], bgG = bg[3*i+1], bb = bg[3*i+2];
const diff = (Math.abs(r - br) + Math.abs(g - bgG) + Math.abs(b - bb)) / 3;
// motion against the remembered room, softened to 0..1
let m = Math.min(1, Math.max(0, (diff - 18) / 40));
trail[i] = Math.max(m, trail[i] * linger);
const a = trail[i] * ghostly;
// the room learns fast while you're away or during "learn", barely at all where something moves
const rate = learning ? .25 : (m < .05 ? .03 : .0015);
bg[3*i] += rate * (r - br); bg[3*i+1] += rate * (g - bgG); bg[3*i+2] += rate * (b - bb);
// see-through: show the room where you moved, with a cold spectral edge glow
const lum = (r + g + b) / 3;
const edge = a * (1 - a) * 4; // strongest at the half-faded rim
const flick = 1 + (Math.random() - .5) * .08 * a;
d[4*i] = ((1 - a) * r + a * (br * .82 + lum * .18 * .7)) * flick + edge * 40;
d[4*i+1] = ((1 - a) * g + a * (bgG * .85 + lum * .18 * .85)) * flick + edge * 70;
d[4*i+2] = ((1 - a) * b + a * (bb * .9 + lum * .18)) * flick + edge * 110;
if (m > .2) { mSum += m; ySum += m * ((i / W) | 0); xSum += m * (i % W); }
}
ctx.putImageData(img, 0, 0);
// theremin: height of your motion is pitch, amount of motion is volume, left/right is wobble
const frac = mSum / N;
energy += (Math.min(1, frac * 12) - energy) * .12;
if (mSum > 30) { cy += (ySum / mSum / H - cy) * .15; cx += (xSum / mSum / W - cx) * .15; }
if (audio) {
const now = audio.ac.currentTime;
const f = 140 * Math.pow(2, (1 - cy) * 3.2); // about 140 Hz to 1300 Hz
audio.osc.frequency.setTargetAtTime(f, now, .06);
audio.osc2.frequency.setTargetAtTime(f * 1.0035, now, .06);
audio.lfoAmt.gain.setTargetAtTime(2 + cx * 14, now, .2);
const v = muted ? 0 : energy * $('vol').value / 100 * .5;
audio.out.gain.setTargetAtTime(v, now, .08);
}
if (learning) { ctx.fillStyle = 'rgba(217,228,255,.85)'; ctx.font = '14px Georgia';
ctx.fillText('learning the empty room… ' + Math.ceil((learnUntil - t) / 1000), 10, 20); }
requestAnimationFrame(loop);
}
</script>
</body>
</html>