Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254import dgram from 'node:dgram';import os from 'node:os';import { Quaternion, Euler, MathUtils } from './dist/vendor/three.mjs';import { neutral, clamp } from './dist/controls.mjs';import { normalizeTrackingSettings } from './tracking-settings.mjs';
export const PHONE_SCHEMA = 'ghost.face/1';export function isLocalAddress(address) { const p = address.split('.').map(Number); return ( p.length === 4 && p.every((n) => Number.isInteger(n) && n >= 0 && n < 256) && (p[0] === 10 || p[0] === 127 || (p[0] === 192 && p[1] === 168) || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)) );}export function connectionAddresses() { return Object.entries(os.networkInterfaces()).flatMap(([name, addresses]) => addresses .filter((a) => a.family === 'IPv4' && !a.internal && isLocalAddress(a.address)) .map((a) => ({ name, address: a.address })), );}export function decodeFacePacket(bytes) { if (bytes.length > 4096) throw Error('Packet too large'); const p = JSON.parse(bytes.toString('utf8')); if ( p.schema !== PHONE_SCHEMA || typeof p.session !== 'string' || !/^[a-zA-Z0-9-]{8,64}$/.test(p.session) || typeof p.tracked !== 'boolean' || !Number.isSafeInteger(p.sequence) || p.sequence < 0 ) throw Error('Unsupported packet'); if (!p.tracked) return p; const vector = (v, n) => Array.isArray(v) && v.length === n && v.every(Number.isFinite); if (!vector(p.head, 4) || !vector(p.gaze, 2) || !vector(p.blink, 2)) throw Error('Invalid pose'); if ( Math.abs(Math.hypot(...p.head) - 1) > 0.02 || p.gaze.some((v) => Math.abs(v) > Math.PI) || p.blink.some((v) => v < 0 || v > 1) ) throw Error('Invalid range'); return p;}
export class PhoneTracker { constructor({ onPose = () => {}, onStatus = () => {}, clock = () => performance.now(), port = 49984, host = '0.0.0.0', settings = {}, } = {}) { this.onPose = onPose; this.onStatus = onStatus; this.clock = clock; this.port = port; this.host = host; this.enabled = true; this.selected = false; this.available = false; this.error = null; this.peer = null; this.tracked = false; this.lastFrame = -Infinity; this.lastAck = -Infinity; this.lastStatus = -Infinity; this.frames = 0; this.fps = 0; this.rateStart = clock(); this.center = null; this.lastSample = null; this.pose = neutral(); this.sequence = -1; this.settings = normalizeTrackingSettings(settings); } get receiving() { return this.available && this.clock() - this.lastFrame < 1200; } get active() { return this.enabled && this.receiving && this.tracked; } status() { return { backend: 'ghost-face', available: this.available, enabled: this.enabled, selected: this.selected, connected: this.receiving, tracked: this.tracked && this.receiving, active: this.active, peer: this.peer, port: this.port, addresses: connectionAddresses(), fps: this.fps, calibrated: !!this.center, error: this.error, settings: { ...this.settings }, }; } emitStatus(force = false) { if (force || this.clock() - this.lastStatus >= 1000) { this.lastStatus = this.clock(); this.onStatus(this.status()); } } configure(data) { if (typeof data.enabled === 'boolean') this.enabled = data.enabled; if (typeof data.selected === 'boolean') this.selected = data.selected; this.settings = normalizeTrackingSettings(data, this.settings); if (data.release === true) { this.peer = null; this.sessionID = null; this.sequence = -1; this.center = null; this.lastSample = null; this.lastFrame = -Infinity; this.tracked = false; this.pose = neutral(); if (this.enabled) this.onPose(this.pose); } if (data.recenter === true && this.lastSample?.tracked) this.setCenter(this.lastSample); if (this.lastSample?.tracked) { this.pose = this.map(this.lastSample); if (this.active) this.onPose(this.pose); } this.emitStatus(true); return this.status(); } setCenter(sample) { this.center = { head: new Quaternion(...sample.head).normalize(), gaze: [...sample.gaze] }; } map(sample) { if (!this.center) this.setCenter(sample); const q = this.center.head .clone() .invert() .multiply(new Quaternion(...sample.head).normalize()); const e = new Euler().setFromQuaternion(q, 'XYZ'), deg = MathUtils.radToDeg, mirror = this.settings.mirror ? -1 : 1; const blink = this.settings.swapEyes ? [sample.blink[1], sample.blink[0]] : sample.blink; const eyesOpen = Math.max(...blink) < 0.65; return { // Clamp the *input* range, then scale it. A 2x multiplier gets 2x travel // instead of reaching the original output ceiling twice as quickly. yaw: clamp(deg(e.y), -30, 30) * this.settings.headGain * this.settings.yawGain * mirror, pitch: clamp(deg(e.x), -20, 20) * this.settings.headGain * this.settings.pitchGain * (this.settings.invertHeadY ? -1 : 1), roll: clamp(deg(e.z), -20, 20) * this.settings.headGain * this.settings.rollGain * mirror, gazeX: eyesOpen ? clamp((sample.gaze[0] - this.center.gaze[0]) / (Math.PI / 7.2), -1, 1) * this.settings.gazeGain * this.settings.gazeXGain * mirror : this.pose.gazeX, gazeY: eyesOpen ? clamp((sample.gaze[1] - this.center.gaze[1]) / (Math.PI / 9), -1, 1) * this.settings.gazeGain * this.settings.gazeYGain * (this.settings.invertGazeY ? -1 : 1) : this.pose.gazeY, blinkL: blink[0], blinkR: blink[1], }; } accept(bytes, info) { if (!isLocalAddress(info.address) || (this.peer && this.peer !== info.address)) return false; let sample; try { sample = decodeFacePacket(bytes); } catch { return false; } const wasActive = this.active, wasConnected = this.receiving; // Sender timestamps/sequences can restart when the phone app starts a new session. if (sample.session === this.sessionID && sample.sequence <= this.sequence && this.receiving) return false; if (sample.session !== this.sessionID) { this.center = null; this.sessionID = sample.session; } this.peer = info.address; this.sequence = sample.sequence; this.lastFrame = this.clock(); this.tracked = sample.tracked; this.lastSample = sample; this.frames++; const elapsed = (this.clock() - this.rateStart) / 1000; if (elapsed >= 1) { this.fps = Math.round(this.frames / elapsed); this.frames = 0; this.rateStart = this.clock(); } if (sample.tracked) { if (sample.recenter === true || !this.center) this.setCenter(sample); this.pose = this.map(sample); if (this.enabled) this.onPose(this.pose); } else if (this.enabled && wasActive) { this.pose = neutral(); this.onPose(this.pose); } this.emitStatus(wasActive !== this.active || wasConnected !== this.receiving); if (this.socket && this.clock() - this.lastAck >= 400) { this.lastAck = this.clock(); this.socket.send( JSON.stringify({ schema: 'ghost.face/ack1', sequence: sample.sequence }), info.port, info.address, () => {}, ); } return true; } start() { this.socket = dgram.createSocket('udp4'); this.socket.on('message', (bytes, info) => this.accept(bytes, info)); this.socket.on('error', (error) => { this.error = error.code === 'EADDRINUSE' ? 'UDP port 49984 is already in use' : error.message; this.available = false; this.tracked = false; this.emitStatus(true); }); this.socket.bind(this.port, this.host, () => { this.port = this.socket.address().port; this.available = true; this.emitStatus(true); }); this.timer = setInterval(() => { if (this.tracked && !this.receiving) { this.tracked = false; this.pose = neutral(); if (this.enabled) this.onPose(this.pose); this.emitStatus(true); } }, 100); } stop() { clearInterval(this.timer); try { this.socket?.close(); } catch {} }}