Upload 2 files
Browse files- README.md +14 -6
- index.html +149 -17
README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description:
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Ghost You
|
| 3 |
+
emoji: 👻
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Move and turn see-through; your motion plays a theremin.
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Ghost You
|
| 14 |
+
|
| 15 |
+
A Halloween webcam toy. Sit still and you are solid; move and you fade until the room shows
|
| 16 |
+
through you. Your motion plays a sine-wave theremin: higher on screen is a higher note, more
|
| 17 |
+
motion is louder.
|
| 18 |
+
|
| 19 |
+
Press **Summon the ghost** and allow the camera. For the best effect, press **Learn empty room**
|
| 20 |
+
and step out of view for 3 seconds. Everything runs in your browser; no video leaves your machine.
|
index.html
CHANGED
|
@@ -1,19 +1,151 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>Ghost You</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg:#07060b; --fg:#d9e4ff; --dim:#7d86a8; --glow:#9fd8ff; --accent:#ff8a3d; }
|
| 9 |
+
* { box-sizing:border-box; }
|
| 10 |
+
html,body { margin:0; height:100%; background:var(--bg); color:var(--fg);
|
| 11 |
+
font:15px/1.45 "Georgia", serif; overflow:hidden; }
|
| 12 |
+
#stage { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; }
|
| 13 |
+
canvas { width:min(100vw, 133.33vh); height:min(75vw, 100vh); image-rendering:auto;
|
| 14 |
+
filter:contrast(1.08) saturate(.85); }
|
| 15 |
+
#veil { position:fixed; inset:0; pointer-events:none;
|
| 16 |
+
background:radial-gradient(ellipse at center, transparent 45%, rgba(4,2,10,.85) 100%); }
|
| 17 |
+
#intro { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center;
|
| 18 |
+
justify-content:center; gap:18px; text-align:center; padding:16px;
|
| 19 |
+
background:radial-gradient(ellipse at center, #15112a 0%, var(--bg) 70%); z-index:5; }
|
| 20 |
+
h1 { font-size:clamp(40px, 9vw, 92px); margin:0; letter-spacing:.04em; font-weight:normal;
|
| 21 |
+
color:var(--fg); text-shadow:0 0 18px var(--glow), 0 0 42px #4a6cff; animation:float 4s ease-in-out infinite; }
|
| 22 |
+
@keyframes float { 50% { transform:translateY(-10px); opacity:.85; } }
|
| 23 |
+
p { max-width:34em; color:var(--dim); margin:0; }
|
| 24 |
+
button { font:inherit; color:var(--bg); background:var(--accent); border:0; border-radius:999px;
|
| 25 |
+
padding:12px 26px; cursor:pointer; box-shadow:0 0 24px rgba(255,138,61,.45); }
|
| 26 |
+
button.ghost { background:transparent; color:var(--fg); border:1px solid #3a3f5c; box-shadow:none; padding:8px 16px; }
|
| 27 |
+
#bar { position:fixed; left:0; right:0; bottom:0; display:flex; flex-wrap:wrap; gap:8px; align-items:center;
|
| 28 |
+
justify-content:center; padding:10px 16px; z-index:4; font-size:13px; color:var(--dim);
|
| 29 |
+
background:linear-gradient(transparent, rgba(7,6,11,.9)); }
|
| 30 |
+
#bar label { display:flex; gap:6px; align-items:center; }
|
| 31 |
+
input[type=range] { width:110px; accent-color:var(--glow); }
|
| 32 |
+
#err { color:var(--accent); }
|
| 33 |
+
</style>
|
| 34 |
+
</head>
|
| 35 |
+
<body>
|
| 36 |
+
<div id="stage"><canvas id="c" width="320" height="240"></canvas></div>
|
| 37 |
+
<div id="veil"></div>
|
| 38 |
+
|
| 39 |
+
<div id="intro">
|
| 40 |
+
<h1>Ghost You</h1>
|
| 41 |
+
<p>Sit still and you are solid. Move, and you fade until the room shows through you.
|
| 42 |
+
Your motion plays a theremin: higher on the screen is a higher note, more motion is louder.</p>
|
| 43 |
+
<p>For the best haunting, step out of view for a few seconds so the room can learn what is behind you.</p>
|
| 44 |
+
<button id="go">Summon the ghost</button>
|
| 45 |
+
<span id="err"></span>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div id="bar" hidden>
|
| 49 |
+
<button class="ghost" id="learn">Learn empty room (3 s)</button>
|
| 50 |
+
<label>ghostliness <input id="ghostly" type="range" min="0" max="100" value="85"></label>
|
| 51 |
+
<label>linger <input id="linger" type="range" min="0" max="98" value="88"></label>
|
| 52 |
+
<label>volume <input id="vol" type="range" min="0" max="100" value="55"></label>
|
| 53 |
+
<button class="ghost" id="mute">mute</button>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<video id="v" playsinline muted hidden></video>
|
| 57 |
+
<script>
|
| 58 |
+
const W = 320, H = 240, N = W * H;
|
| 59 |
+
const cv = document.getElementById('c'), ctx = cv.getContext('2d', { willReadFrequently:true });
|
| 60 |
+
const video = document.getElementById('v');
|
| 61 |
+
const bg = new Float32Array(N * 3); // the room's memory: slow average of what is behind you
|
| 62 |
+
const trail = new Float32Array(N); // lingering ghost mask
|
| 63 |
+
let haveBg = false, learnUntil = 0, audio = null, muted = false;
|
| 64 |
+
const $ = id => document.getElementById(id);
|
| 65 |
+
|
| 66 |
+
$('go').onclick = async () => {
|
| 67 |
+
try {
|
| 68 |
+
const stream = await navigator.mediaDevices.getUserMedia({ video:{ width:640, height:480, facingMode:'user' }, audio:false });
|
| 69 |
+
video.srcObject = stream; await video.play();
|
| 70 |
+
audio = makeTheremin();
|
| 71 |
+
$('intro').remove(); $('bar').hidden = false;
|
| 72 |
+
requestAnimationFrame(loop);
|
| 73 |
+
} catch (e) { $('err').textContent = 'The camera would not open: ' + (e.message || e); }
|
| 74 |
+
};
|
| 75 |
+
$('learn').onclick = () => { learnUntil = performance.now() + 3000; };
|
| 76 |
+
$('mute').onclick = () => { muted = !muted; $('mute').textContent = muted ? 'unmute' : 'mute'; };
|
| 77 |
+
|
| 78 |
+
function makeTheremin() {
|
| 79 |
+
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
| 80 |
+
const out = ac.createGain(); out.gain.value = 0;
|
| 81 |
+
const osc = ac.createOscillator(); osc.type = 'sine'; osc.frequency.value = 440;
|
| 82 |
+
const osc2 = ac.createOscillator(); osc2.type = 'sine'; osc2.frequency.value = 441.5;
|
| 83 |
+
const g2 = ac.createGain(); g2.gain.value = .35;
|
| 84 |
+
const lfo = ac.createOscillator(); lfo.frequency.value = 5.6;
|
| 85 |
+
const lfoAmt = ac.createGain(); lfoAmt.gain.value = 6;
|
| 86 |
+
lfo.connect(lfoAmt); lfoAmt.connect(osc.frequency); lfoAmt.connect(osc2.frequency);
|
| 87 |
+
// a little haunted-hallway echo
|
| 88 |
+
const delay = ac.createDelay(1); delay.delayTime.value = .33;
|
| 89 |
+
const fb = ac.createGain(); fb.gain.value = .38;
|
| 90 |
+
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 1800;
|
| 91 |
+
osc.connect(out); osc2.connect(g2); g2.connect(out);
|
| 92 |
+
out.connect(ac.destination); out.connect(delay); delay.connect(lp); lp.connect(fb); fb.connect(delay); lp.connect(ac.destination);
|
| 93 |
+
osc.start(); osc2.start(); lfo.start();
|
| 94 |
+
return { ac, out, osc, osc2, lfoAmt };
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
let energy = 0, cy = .5, cx = .5;
|
| 98 |
+
function loop(t) {
|
| 99 |
+
// mirror the camera so it behaves like a mirror
|
| 100 |
+
ctx.save(); ctx.translate(W, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, W, H); ctx.restore();
|
| 101 |
+
const img = ctx.getImageData(0, 0, W, H), d = img.data;
|
| 102 |
+
const learning = t < learnUntil;
|
| 103 |
+
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; }
|
| 104 |
+
|
| 105 |
+
const ghostly = $('ghostly').value / 100, linger = $('linger').value / 100;
|
| 106 |
+
let mSum = 0, ySum = 0, xSum = 0;
|
| 107 |
+
for (let i = 0; i < N; i++) {
|
| 108 |
+
const r = d[4*i], g = d[4*i+1], b = d[4*i+2];
|
| 109 |
+
const br = bg[3*i], bgG = bg[3*i+1], bb = bg[3*i+2];
|
| 110 |
+
const diff = (Math.abs(r - br) + Math.abs(g - bgG) + Math.abs(b - bb)) / 3;
|
| 111 |
+
// motion against the remembered room, softened to 0..1
|
| 112 |
+
let m = Math.min(1, Math.max(0, (diff - 18) / 40));
|
| 113 |
+
trail[i] = Math.max(m, trail[i] * linger);
|
| 114 |
+
const a = trail[i] * ghostly;
|
| 115 |
+
|
| 116 |
+
// the room learns fast while you're away or during "learn", barely at all where something moves
|
| 117 |
+
const rate = learning ? .25 : (m < .05 ? .03 : .0015);
|
| 118 |
+
bg[3*i] += rate * (r - br); bg[3*i+1] += rate * (g - bgG); bg[3*i+2] += rate * (b - bb);
|
| 119 |
+
|
| 120 |
+
// see-through: show the room where you moved, with a cold spectral edge glow
|
| 121 |
+
const lum = (r + g + b) / 3;
|
| 122 |
+
const edge = a * (1 - a) * 4; // strongest at the half-faded rim
|
| 123 |
+
const flick = 1 + (Math.random() - .5) * .08 * a;
|
| 124 |
+
d[4*i] = ((1 - a) * r + a * (br * .82 + lum * .18 * .7)) * flick + edge * 40;
|
| 125 |
+
d[4*i+1] = ((1 - a) * g + a * (bgG * .85 + lum * .18 * .85)) * flick + edge * 70;
|
| 126 |
+
d[4*i+2] = ((1 - a) * b + a * (bb * .9 + lum * .18)) * flick + edge * 110;
|
| 127 |
+
|
| 128 |
+
if (m > .2) { mSum += m; ySum += m * ((i / W) | 0); xSum += m * (i % W); }
|
| 129 |
+
}
|
| 130 |
+
ctx.putImageData(img, 0, 0);
|
| 131 |
+
|
| 132 |
+
// theremin: height of your motion is pitch, amount of motion is volume, left/right is wobble
|
| 133 |
+
const frac = mSum / N;
|
| 134 |
+
energy += (Math.min(1, frac * 12) - energy) * .12;
|
| 135 |
+
if (mSum > 30) { cy += (ySum / mSum / H - cy) * .15; cx += (xSum / mSum / W - cx) * .15; }
|
| 136 |
+
if (audio) {
|
| 137 |
+
const now = audio.ac.currentTime;
|
| 138 |
+
const f = 140 * Math.pow(2, (1 - cy) * 3.2); // about 140 Hz to 1300 Hz
|
| 139 |
+
audio.osc.frequency.setTargetAtTime(f, now, .06);
|
| 140 |
+
audio.osc2.frequency.setTargetAtTime(f * 1.0035, now, .06);
|
| 141 |
+
audio.lfoAmt.gain.setTargetAtTime(2 + cx * 14, now, .2);
|
| 142 |
+
const v = muted ? 0 : energy * $('vol').value / 100 * .5;
|
| 143 |
+
audio.out.gain.setTargetAtTime(v, now, .08);
|
| 144 |
+
}
|
| 145 |
+
if (learning) { ctx.fillStyle = 'rgba(217,228,255,.85)'; ctx.font = '14px Georgia';
|
| 146 |
+
ctx.fillText('learning the empty room… ' + Math.ceil((learnUntil - t) / 1000), 10, 20); }
|
| 147 |
+
requestAnimationFrame(loop);
|
| 148 |
+
}
|
| 149 |
+
</script>
|
| 150 |
+
</body>
|
| 151 |
</html>
|