Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-12 11:28:40 +00:00
parent 4699676514
commit c986cb8c15
+147
View File
@@ -0,0 +1,147 @@
/**
* DEVKIT — Macro recorder/player for fake controller inputs.
*
* Records fakeInput poses + grip/trigger at ~30Hz to a JSON-serializable
* timeline. Replays by writing values back into fakeInput. Used to reproduce
* bugs reported by the headset tester.
*/
import * as THREE from 'three';
import { fakeInput } from './fakeInputStore';
export interface MacroFrame {
t: number; // ms since start
l: { m: number[]; g: number; t: number };
r: { m: number[]; g: number; t: number };
}
export interface Macro {
name: string;
duration: number;
frames: MacroFrame[];
}
type Phase = 'idle' | 'recording' | 'playing';
interface State {
phase: Phase;
recStart: number;
rec: MacroFrame[];
play: { macro: Macro; start: number; idx: number } | null;
current: Macro | null;
listeners: Set<() => void>;
}
export const macroState: State = {
phase: 'idle',
recStart: 0,
rec: [],
play: null,
current: null,
listeners: new Set(),
};
function notify() { for (const l of macroState.listeners) l(); }
export function subscribeMacro(cb: () => void) {
macroState.listeners.add(cb);
return () => macroState.listeners.delete(cb);
}
export function startRecording() {
macroState.phase = 'recording';
macroState.recStart = performance.now();
macroState.rec = [];
notify();
}
export function stopRecording(): Macro {
const macro: Macro = {
name: `macro-${new Date().toISOString().slice(11, 19)}`,
duration: performance.now() - macroState.recStart,
frames: macroState.rec.slice(),
};
macroState.phase = 'idle';
macroState.current = macro;
notify();
return macro;
}
export function startPlaying(macro?: Macro) {
const m = macro ?? macroState.current;
if (!m || m.frames.length === 0) return;
macroState.play = { macro: m, start: performance.now(), idx: 0 };
macroState.phase = 'playing';
notify();
}
export function stopPlaying() {
macroState.play = null;
macroState.phase = 'idle';
notify();
}
export function tickMacro() {
if (macroState.phase === 'recording') {
const t = performance.now() - macroState.recStart;
// throttle to ~30Hz
const last = macroState.rec[macroState.rec.length - 1];
if (!last || t - last.t > 33) {
macroState.rec.push({
t,
l: { m: matrixToArray(fakeInput.left.gripWorld), g: fakeInput.left.gripValue, t: fakeInput.left.triggerValue },
r: { m: matrixToArray(fakeInput.right.gripWorld), g: fakeInput.right.gripValue, t: fakeInput.right.triggerValue },
});
}
} else if (macroState.phase === 'playing' && macroState.play) {
const t = performance.now() - macroState.play.start;
const frames = macroState.play.macro.frames;
while (
macroState.play.idx < frames.length - 1 &&
frames[macroState.play.idx + 1].t <= t
) {
macroState.play.idx++;
}
const f = frames[macroState.play.idx];
if (f) {
arrayToMatrix(f.l.m, fakeInput.left.gripWorld);
fakeInput.left.gripValue = f.l.g;
fakeInput.left.triggerValue = f.l.t;
arrayToMatrix(f.r.m, fakeInput.right.gripWorld);
fakeInput.right.gripValue = f.r.g;
fakeInput.right.triggerValue = f.r.t;
}
if (t > macroState.play.macro.duration) stopPlaying();
}
}
export function isPlaybackActive() {
return macroState.phase === 'playing';
}
function matrixToArray(m: THREE.Matrix4): number[] {
return Array.from(m.elements);
}
function arrayToMatrix(arr: number[], target: THREE.Matrix4) {
target.fromArray(arr);
}
export function downloadMacro(macro: Macro) {
const blob = new Blob([JSON.stringify(macro, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${macro.name}.json`;
a.click();
URL.revokeObjectURL(url);
}
export function loadMacroFromFile(file: File): Promise<Macro> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
try { resolve(JSON.parse(String(r.result)) as Macro); } catch (e) { reject(e); }
};
r.onerror = reject;
r.readAsText(file);
});
}