Neural-MRI / frontend /src /store /useRecordingStore.ts
Hiconcep's picture
Upload folder using huggingface_hub
0ce9643 verified
import { create } from 'zustand';
import type { RecordingFrame, ScanRecording, PlaybackSpeed } from '../types/recording';
import { useScanStore } from './useScanStore';
import { useModelStore } from './useModelStore';
import { downloadFile } from '../utils/exportUtils';
interface RecordingState {
// Recording
isRecording: boolean;
frames: RecordingFrame[];
recordingStart: number;
// Playback
recording: ScanRecording | null;
isPlaying: boolean;
playbackSpeed: PlaybackSpeed;
currentFrameIdx: number;
// Computed-like getter
currentFrame: RecordingFrame | null;
// Actions — recording
startRecording: () => void;
stopRecording: () => void;
saveRecording: () => void;
loadRecording: (file: File) => void;
// Actions — playback
play: () => void;
pause: () => void;
stop: () => void;
seekTo: (frameIdx: number) => void;
setSpeed: (speed: PlaybackSpeed) => void;
}
let _recordUnsub: (() => void) | null = null;
let _playbackRaf = 0;
let _playbackStartTime = 0;
let _playbackStartOffset = 0;
function _captureFrame(startTime: number): RecordingFrame {
const s = useScanStore.getState();
return {
timestamp: Date.now() - startTime,
mode: s.mode,
selectedTokenIdx: s.selectedTokenIdx,
structuralData: s.structuralData,
weightData: s.weightData,
activationData: s.activationData,
circuitData: s.circuitData,
anomalyData: s.anomalyData,
};
}
function _framesAreSame(a: RecordingFrame, b: RecordingFrame): boolean {
return (
a.mode === b.mode &&
a.selectedTokenIdx === b.selectedTokenIdx &&
a.structuralData === b.structuralData &&
a.weightData === b.weightData &&
a.activationData === b.activationData &&
a.circuitData === b.circuitData &&
a.anomalyData === b.anomalyData
);
}
export const useRecordingStore = create<RecordingState>((set, get) => ({
isRecording: false,
frames: [],
recordingStart: 0,
recording: null,
isPlaying: false,
playbackSpeed: 1,
currentFrameIdx: 0,
currentFrame: null,
startRecording: () => {
// Stop any playback
get().stop();
const startTime = Date.now();
const initialFrame = _captureFrame(startTime);
set({
isRecording: true,
frames: [initialFrame],
recordingStart: startTime,
});
// Subscribe to scan store changes
if (_recordUnsub) _recordUnsub();
let prevIsScanning = useScanStore.getState().isScanning;
_recordUnsub = useScanStore.subscribe((state) => {
const { isRecording, frames, recordingStart } = get();
if (!isRecording) return;
// Capture on scan completion
const scanCompleted = prevIsScanning && !state.isScanning;
prevIsScanning = state.isScanning;
if (state.isScanning) return; // Don't capture mid-scan
const newFrame = _captureFrame(recordingStart);
const lastFrame = frames[frames.length - 1];
// Skip duplicate frames
if (lastFrame && _framesAreSame(lastFrame, newFrame) && !scanCompleted) {
return;
}
set({ frames: [...frames, newFrame] });
});
useScanStore.getState().addLog('Recording started');
},
stopRecording: () => {
if (_recordUnsub) {
_recordUnsub();
_recordUnsub = null;
}
const { frames, recordingStart } = get();
if (frames.length === 0) {
set({ isRecording: false });
return;
}
const modelInfo = useModelStore.getState().modelInfo;
const scanState = useScanStore.getState();
const recording: ScanRecording = {
version: 1,
modelId: modelInfo?.model_id ?? 'unknown',
prompt: scanState.prompt,
createdAt: new Date(recordingStart).toISOString(),
duration: Date.now() - recordingStart,
frames,
};
set({
isRecording: false,
recording,
currentFrameIdx: 0,
currentFrame: frames[0] ?? null,
});
useScanStore.getState().addLog(`Recording stopped: ${frames.length} frames`);
},
saveRecording: () => {
const { recording } = get();
if (!recording) return;
const json = JSON.stringify(recording, null, 2);
const ts = new Date().toISOString().slice(0, 10);
downloadFile(
new Blob([json], { type: 'application/json' }),
`nmri-recording-${recording.modelId}-${ts}.json`,
);
useScanStore.getState().addLog('Recording saved');
},
loadRecording: (file: File) => {
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result as string) as ScanRecording;
if (!data.version || !data.frames || data.frames.length === 0) {
useScanStore.getState().addLog('Invalid recording file');
return;
}
set({
recording: data,
currentFrameIdx: 0,
currentFrame: data.frames[0],
isPlaying: false,
});
useScanStore
.getState()
.addLog(`Recording loaded: ${data.frames.length} frames, ${data.modelId}`);
} catch {
useScanStore.getState().addLog('Failed to parse recording file');
}
};
reader.readAsText(file);
},
play: () => {
const { recording, isPlaying, currentFrameIdx, playbackSpeed } = get();
if (!recording || recording.frames.length === 0 || isPlaying) return;
const currentTimestamp = recording.frames[currentFrameIdx]?.timestamp ?? 0;
_playbackStartTime = performance.now();
_playbackStartOffset = currentTimestamp;
set({ isPlaying: true });
const tick = () => {
const state = get();
if (!state.isPlaying || !state.recording) return;
const elapsed = (performance.now() - _playbackStartTime) * playbackSpeed;
const targetTime = _playbackStartOffset + elapsed;
// Find the frame closest to targetTime
let frameIdx = state.currentFrameIdx;
const frames = state.recording.frames;
while (frameIdx < frames.length - 1 && frames[frameIdx + 1].timestamp <= targetTime) {
frameIdx++;
}
if (frameIdx !== state.currentFrameIdx) {
set({
currentFrameIdx: frameIdx,
currentFrame: frames[frameIdx],
});
}
// Stop at end
if (frameIdx >= frames.length - 1) {
set({ isPlaying: false });
return;
}
_playbackRaf = requestAnimationFrame(tick);
};
_playbackRaf = requestAnimationFrame(tick);
},
pause: () => {
cancelAnimationFrame(_playbackRaf);
set({ isPlaying: false });
},
stop: () => {
cancelAnimationFrame(_playbackRaf);
const { recording } = get();
set({
isPlaying: false,
currentFrameIdx: 0,
currentFrame: recording?.frames[0] ?? null,
});
},
seekTo: (frameIdx: number) => {
const { recording } = get();
if (!recording) return;
const clamped = Math.max(0, Math.min(frameIdx, recording.frames.length - 1));
set({
currentFrameIdx: clamped,
currentFrame: recording.frames[clamped],
});
},
setSpeed: (speed: PlaybackSpeed) => {
const { isPlaying, currentFrameIdx, recording } = get();
set({ playbackSpeed: speed });
// If playing, restart timing from current position
if (isPlaying && recording) {
_playbackStartTime = performance.now();
_playbackStartOffset = recording.frames[currentFrameIdx]?.timestamp ?? 0;
}
},
}));