feat: optimize event capturing and playback accuracy

This commit is contained in:
2026-02-15 18:06:50 +01:00
parent e9ce406a31
commit 4e762ebfdf
38 changed files with 2847 additions and 523 deletions

32
remotion/Root.tsx Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Composition } from 'remotion';
import { WebsiteVideo } from './WebsiteVideo';
import sessionData from './session.json';
import { RecordingSession } from '../types/record-mode';
const FPS = 60;
export const RemotionRoot: React.FC = () => {
// Calculate duration based on last event + padding
const durationMs = (sessionData as unknown as RecordingSession).events.reduce((max, e) => {
return Math.max(max, e.timestamp + (e.duration || 1000));
}, 0);
const durationInFrames = Math.ceil((durationMs + 2000) / 1000 * FPS);
return (
<>
<Composition
id="WebsiteVideo"
component={WebsiteVideo}
durationInFrames={durationInFrames}
fps={FPS}
width={1920}
height={1080}
defaultProps={{
session: sessionData as unknown as RecordingSession,
siteUrl: 'http://localhost:3000'
}}
/>
</>
);
};

107
remotion/WebsiteVideo.tsx Normal file
View File

@@ -0,0 +1,107 @@
import React, { useMemo } from 'react';
import { AbsoluteFill, useVideoConfig, useCurrentFrame, interpolate, spring, Easing } from 'remotion';
import { RecordingSession, RecordEvent } from '../types/record-mode';
export const WebsiteVideo: React.FC<{
session: RecordingSession | null;
siteUrl: string;
}> = ({ session, siteUrl }) => {
const { fps, width, height, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
if (!session || !session.events.length) {
return (
<AbsoluteFill style={{ backgroundColor: 'black', color: 'white', justifyContent: 'center', alignItems: 'center' }}>
No session data found.
</AbsoluteFill>
);
}
const sortedEvents = useMemo(() => {
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
}, [session]);
const elapsedTimeMs = (frame / fps) * 1000;
// --- Interpolation Logic ---
// 1. Find the current window (between which two events are we?)
const nextEventIndex = sortedEvents.findIndex(e => e.timestamp > elapsedTimeMs);
let currentEventIndex;
if (nextEventIndex === -1) {
// We are past the last event, stay at the end
currentEventIndex = sortedEvents.length - 1;
} else {
currentEventIndex = Math.max(0, nextEventIndex - 1);
}
const currentEvent = sortedEvents[currentEventIndex];
// If there is no next event, we just stay at current (next=current)
const nextEvent = (nextEventIndex !== -1) ? sortedEvents[nextEventIndex] : currentEvent;
// 2. Calculate Progress between events
const gap = nextEvent.timestamp - currentEvent.timestamp;
const progress = gap > 0 ? (elapsedTimeMs - currentEvent.timestamp) / gap : 1;
const easedProgress = Easing.cubic(Math.min(Math.max(progress, 0), 1));
// 3. Calculate Cursor Position from Rects
const getCenter = (event: RecordEvent) => {
if (event.rect) {
return {
x: event.rect.x + event.rect.width / 2,
y: event.rect.y + event.rect.height / 2
};
}
return { x: width / 2, y: height / 2 };
};
const p1 = getCenter(currentEvent);
const p2 = getCenter(nextEvent);
const cursorX = interpolate(easedProgress, [0, 1], [p1.x, p2.x]);
const cursorY = interpolate(easedProgress, [0, 1], [p1.y, p2.y]);
// 4. Zoom & Blur
const zoom = interpolate(easedProgress, [0, 1], [currentEvent.zoom || 1, nextEvent.zoom || 1]);
const isBlurry = currentEvent.motionBlur || nextEvent.motionBlur;
return (
<AbsoluteFill style={{ backgroundColor: '#000' }}>
<div style={{
width: '100%',
height: '100%',
position: 'relative',
transform: `scale(${zoom})`,
transformOrigin: `${cursorX}px ${cursorY}px`,
filter: isBlurry ? 'blur(8px)' : 'none',
transition: 'filter 0.1s ease-out'
}}>
<iframe
src={siteUrl}
style={{ width: '100%', height: '100%', border: 'none' }}
title="Website"
/>
</div>
{/* Visual Cursor */}
<div style={{
position: 'absolute',
left: cursorX,
top: cursorY,
width: 34, height: 34,
backgroundColor: 'white',
borderRadius: '50%',
border: '3px solid black',
boxShadow: '0 4px 15px rgba(0,0,0,0.4)',
transform: 'translate(-50%, -50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100
}}>
<div style={{ width: 12, height: 12, backgroundColor: '#3b82f6', borderRadius: '50%' }} />
</div>
</AbsoluteFill>
);
};

4
remotion/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);

35
remotion/session.json Normal file
View File

@@ -0,0 +1,35 @@
{
"id": "sample-session",
"name": "Sample Recording",
"createdAt": "2024-03-20T10:00:00.000Z",
"events": [
{
"id": "1",
"type": "click",
"timestamp": 1000,
"duration": 1000,
"zoom": 1,
"selector": "body",
"rect": {
"x": 100,
"y": 100,
"width": 50,
"height": 50
}
},
{
"id": "2",
"type": "scroll",
"timestamp": 2500,
"duration": 1500,
"zoom": 1,
"selector": "footer",
"rect": {
"x": 500,
"y": 800,
"width": 100,
"height": 50
}
}
]
}