File size: 8,005 Bytes
c955bba e438cc9 c955bba | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | <!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>
|