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>