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
+17
@@ -354,6 +354,7 @@ limitations under the License.
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
aria-label="录像进度"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
@@ -361,6 +362,22 @@ limitations under the License.
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
@if (videoStepMarkers().length > 0) {
|
||||
<div class="video-markers" aria-label="录像关键步骤">
|
||||
@for (marker of videoStepMarkers(); track marker.time) {
|
||||
<button
|
||||
type="button"
|
||||
class="video-marker"
|
||||
[class.passed]="currentTime() >= marker.time"
|
||||
[class.failed]="marker.failed"
|
||||
[style.left.%]="marker.percent"
|
||||
[title]="videoMarkerLabel(marker)"
|
||||
[attr.aria-label]="'跳转到' + videoMarkerLabel(marker)"
|
||||
(click)="seekToVideoMarker(marker)"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
|
||||
+32
@@ -724,6 +724,38 @@
|
||||
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
||||
}
|
||||
|
||||
.video-markers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
|
||||
.video-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #64748b;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
|
||||
&.passed { background: #0284c7; }
|
||||
&.failed { background: #ef4444; }
|
||||
&:hover, &:focus-visible {
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%) scale(1.35);
|
||||
outline: 2px solid #38bdf8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-ticks-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
||||
|
||||
describe('FloatingVideoPlayerComponent video markers', () => {
|
||||
it('shows a recorded step on the video timeline and seeks when clicked', () => {
|
||||
const start = 1_800_000_000;
|
||||
const frame: StepReplayFrame = {
|
||||
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
||||
actionText: '点击设置', timestamp: start + 5
|
||||
};
|
||||
const agentService = {
|
||||
isVideoWindowOpen: signal(true),
|
||||
isVideoMinimized: signal(false),
|
||||
recordingPlaybackStatus: signal('ready'),
|
||||
playerMode: signal('video'),
|
||||
hasCurrentSessionStepFrames: signal(true),
|
||||
activeVideoTitle: signal('测试录像'),
|
||||
isVideoLoading: signal(false),
|
||||
activeVideoUrl: signal('/test.mp4'),
|
||||
activeVideoSegments: signal([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
|
||||
currentSessionStepFrames: signal([frame]),
|
||||
currentSession: signal({ session_id: 'test', start_time: start }),
|
||||
currentSessionId: signal('test'),
|
||||
videoSeekRequest: signal(null),
|
||||
stepSeekRequest: signal(null)
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FloatingVideoPlayerComponent],
|
||||
providers: [{ provide: AgentService, useValue: agentService }]
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||
fixture.detectChanges();
|
||||
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker.getAttribute('aria-label')).toContain('步骤 1');
|
||||
expect(marker.style.left).toBe('25%');
|
||||
|
||||
const seek = spyOn(fixture.componentInstance, 'seek');
|
||||
marker.click();
|
||||
expect(seek).toHaveBeenCalledOnceWith(5);
|
||||
});
|
||||
});
|
||||
+20
@@ -34,6 +34,7 @@ import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
||||
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
@@ -83,6 +84,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
private stepTimer: any = null;
|
||||
|
||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||
this.stepFrames(),
|
||||
this.agentService.currentSession()?.start_time,
|
||||
this.agentService.activeVideoSegments(),
|
||||
this.duration()
|
||||
));
|
||||
public totalStepFrames = computed(() => this.stepFrames().length);
|
||||
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
||||
const frames = this.stepFrames();
|
||||
@@ -394,6 +401,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
||||
}
|
||||
|
||||
public seekToVideoMarker(marker: VideoStepMarker): void {
|
||||
this.seek(marker.time);
|
||||
}
|
||||
|
||||
public videoMarkerLabel(marker: VideoStepMarker): string {
|
||||
const first = marker.steps[0];
|
||||
const count = marker.steps.length;
|
||||
const action = first.actionText || first.title;
|
||||
return count > 1
|
||||
? `步骤 ${first.stepNumber} 等 ${count} 步 · ${this.formatTime(marker.time)} · ${action}`
|
||||
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||
}
|
||||
|
||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||
public seek(seconds: number, autoplay = false): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
|
||||
@@ -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