feat: add step markers to recording timeline
This commit is contained in:
1 parent
d31eb2a19d
commit
868f611c36
6 files changed
+220
No files matched your search
@@ -0,0 +1,53 @@
|
||||
import { StepReplayFrame } from '../core/models/stream.model';
|
||||
import { TimelineSegment } from './recording-timeline.util';
|
||||
import { buildVideoStepMarkers } from './video-step-markers.util';
|
||||
|
||||
describe('buildVideoStepMarkers', () => {
|
||||
const start = 1_800_000_000;
|
||||
const segments: TimelineSegment[] = [
|
||||
{ start: 0, duration: 10, offset_ms: 1000, duration_ms: 10000 },
|
||||
{ start: 10, duration: 10, offset_ms: 13000, duration_ms: 10000 }
|
||||
];
|
||||
const frame = (stepNumber: number, seconds: number, status = 'dispatched'): StepReplayFrame => ({
|
||||
index: stepNumber - 1,
|
||||
stepNumber,
|
||||
title: `步骤 ${stepNumber}`,
|
||||
imageUrl: 'step.png',
|
||||
timestamp: start + seconds,
|
||||
status
|
||||
});
|
||||
|
||||
it('places markers on the playable timeline across recording restart gaps', () => {
|
||||
const markers = buildVideoStepMarkers([frame(1, 4), frame(2, 15)], start, segments, 20);
|
||||
expect(markers.map((marker) => marker.time)).toEqual([3, 12]);
|
||||
expect(markers.map((marker) => marker.percent)).toEqual([15, 60]);
|
||||
});
|
||||
|
||||
it('snaps a step in a recording gap to the following segment', () => {
|
||||
const markers = buildVideoStepMarkers([frame(1, 12)], start, segments, 20);
|
||||
expect(markers[0].time).toBe(10);
|
||||
});
|
||||
|
||||
it('omits steps outside the actual recording and without timestamps', () => {
|
||||
const missing = { ...frame(4, 5), timestamp: undefined };
|
||||
const markers = buildVideoStepMarkers(
|
||||
[frame(1, 0), frame(2, 2), frame(3, 24), missing], start, segments, 20
|
||||
);
|
||||
expect(markers.map((marker) => marker.steps[0].stepNumber)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('groups crowded steps and keeps failure information', () => {
|
||||
const markers = buildVideoStepMarkers(
|
||||
[frame(1, 4), frame(2, 4.1, 'failed'), frame(3, 8)], start, segments, 20
|
||||
);
|
||||
expect(markers.length).toBe(2);
|
||||
expect(markers[0].steps.map((step) => step.stepNumber)).toEqual([1, 2]);
|
||||
expect(markers[0].failed).toBeTrue();
|
||||
});
|
||||
|
||||
it('uses session seconds directly for a legacy playlist or single video', () => {
|
||||
expect(buildVideoStepMarkers([frame(1, 6)], start, [{ start: 0, duration: 20 }], 20)[0].time).toBe(6);
|
||||
expect(buildVideoStepMarkers([frame(1, 6)], start, [], 20)[0].time).toBe(6);
|
||||
expect(buildVideoStepMarkers([frame(1, 6)], undefined, [], 20)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { StepReplayFrame } from '../core/models/stream.model';
|
||||
import { hasSessionOffsets, sessionTimeToTimelineTime, TimelineSegment } from './recording-timeline.util';
|
||||
|
||||
export interface VideoStepMarker {
|
||||
time: number;
|
||||
percent: number;
|
||||
steps: StepReplayFrame[];
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** Place recorded action steps on the video's gap-free playback timeline. */
|
||||
export function buildVideoStepMarkers(
|
||||
frames: readonly StepReplayFrame[],
|
||||
sessionStart: number | undefined,
|
||||
segments: readonly TimelineSegment[],
|
||||
duration: number
|
||||
): VideoStepMarker[] {
|
||||
const start = Number(sessionStart);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) return [];
|
||||
|
||||
const hasOffsets = hasSessionOffsets(segments);
|
||||
const firstOffset = hasOffsets ? (segments[0].offset_ms as number) / 1000 : 0;
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const lastOffset = hasOffsets
|
||||
? ((lastSegment.offset_ms as number) + (lastSegment.duration_ms as number)) / 1000
|
||||
: duration;
|
||||
|
||||
const positioned = frames.flatMap((frame) => {
|
||||
const timestamp = Number(frame.timestamp);
|
||||
if (frame.timestamp == null || !Number.isFinite(timestamp)) return [];
|
||||
const sessionTime = timestamp - start;
|
||||
// Do not create misleading ticks at the start/end for steps outside the recording.
|
||||
if (sessionTime < firstOffset || sessionTime > lastOffset) return [];
|
||||
const time = sessionTimeToTimelineTime(segments, sessionTime);
|
||||
if (time < 0 || time > duration) return [];
|
||||
return [{ time, frame }];
|
||||
}).sort((a, b) => a.time - b.time);
|
||||
|
||||
const markers: VideoStepMarker[] = [];
|
||||
// Nearby actions share one visible tick on a narrow player timeline.
|
||||
const minGap = duration * 0.02;
|
||||
for (const { time, frame } of positioned) {
|
||||
const previous = markers[markers.length - 1];
|
||||
if (previous && time - previous.time < minGap) {
|
||||
previous.steps.push(frame);
|
||||
previous.failed ||= frame.status === 'failed';
|
||||
} else {
|
||||
markers.push({ time, percent: (time / duration) * 100, steps: [frame], failed: frame.status === 'failed' });
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
Reference in new issue
Block a user