feat: add step-by-step visual replay and harden process lifecycle

- UI: Implement dual-mode playback in FloatingVideoPlayer supporting video and step-by-step frame replay with scrub controls and auto-fallback
- Runtime: Prevent asyncio event loop hangs on Darwin and Linux by avoiding psutil.wait_procs on direct child processes
- Task Queue: Add worker exit watchdog and automatic recording recovery for completed or interrupted sessions
- Recording: Add ffmpeg ultrafast transcode fallback, shield recording finalization against cancellation, and handle on-the-fly MP4 conversion
- Docs: Polish README_CN styling and streamline flash runner agent prompt
This commit is contained in:
somew1nd committed 2026-08-26 00:53:24 -07:00
1 parent 06643e955b
commit a36a66ce6d
26 files changed
+1152 -117

No files matched your search

@@ -263,28 +263,41 @@
[class.recording]="agentService.isCurrentSessionRunning()"
[class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
[class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
[class.failed]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed'"
(click)="agentService.toggleVideoPlayer()"
[title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionRecordingStatus() === 'processing' ? 'Preparing screen recording' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording'))"
[title]="getScreenRecordingButtonTitle()"
>
<span class="material-symbols-outlined video-icon" *ngIf="agentService.isCurrentSessionRunning()">
videocam
</span>
<span class="material-symbols-outlined video-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'">
<span class="material-symbols-outlined video-icon spin-animation" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'">
progress_activity
</span>
<span class="material-symbols-outlined video-icon play-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()">
smart_display
</span>
<span class="material-symbols-outlined video-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl()">
<span class="material-symbols-outlined video-icon steps-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()">
slideshow
</span>
<span class="material-symbols-outlined video-icon error-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed'">
videocam_off
</span>
<span class="material-symbols-outlined video-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() !== 'failed'">
videocam
</span>
<span class="btn-text" *ngIf="agentService.isCurrentSessionRunning()">Recording...</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'">Preparing...</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()">Play Recording</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl()">Screen Recording</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()">Step Replay</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed'">Recording Failed</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() !== 'failed'">Screen Recording</span>
<span class="video-pulse-dot red" *ngIf="agentService.isCurrentSessionRunning()"></span>
<span class="steps-count-badge" *ngIf="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()">
{{ agentService.currentSessionStepFrames().length }}
</span>
</button>
</div>
@@ -936,6 +936,56 @@ $stream-row-height: 25px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
&.has-steps {
background: rgba(240, 253, 250, 0.9);
border-color: rgba(94, 234, 212, 0.7);
box-shadow: 0 2px 6px -1px rgba(13, 148, 136, 0.12), inset 0 1px 1px rgba(255, 255, 255, 0.9);
.steps-icon {
color: #0d9488;
}
.btn-text {
color: #0f766e;
}
&:hover:not(.active) {
background: rgba(204, 251, 241, 0.95);
border-color: rgba(45, 212, 191, 0.85);
.steps-icon {
color: #0f766e;
}
}
}
&.failed {
background: rgba(254, 242, 242, 0.8);
border-color: rgba(254, 202, 202, 0.9);
.error-icon {
color: #ef4444;
}
.btn-text {
color: #dc2626;
}
}
.steps-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 5px;
border-radius: 10px;
background: #0d9488;
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 1;
margin-left: 2px;
}
&.active {
background: rgba(26, 115, 232, 0.95);
border-color: rgba(26, 115, 232, 0.95);
@@ -949,6 +999,11 @@ $stream-row-height: 25px;
background: #ffffff;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.9);
}
.steps-count-badge {
background: #ffffff;
color: #1a73e8;
}
}
}
}
@@ -936,6 +936,27 @@ export class AgentStreamComponent implements AfterViewInit {
}
}
public getScreenRecordingButtonTitle(): string {
if (this.agentService.isCurrentSessionRunning()) {
return 'Task is currently running (Recording screen)';
}
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
return 'Preparing screen recording...';
}
if (this.agentService.currentSessionVideoUrl()) {
return 'Play Screen Recording Video';
}
if (this.agentService.hasCurrentSessionStepFrames()) {
return this.agentService.currentSessionRecordingStatus() === 'failed'
? 'Video recording failed — Click to replay step-by-step screenshots'
: 'Play step-by-step screenshots replay';
}
if (this.agentService.currentSessionRecordingStatus() === 'failed') {
return 'Screen recording generation failed';
}
return 'Screen Recording';
}
public isDeviceActionTool(tool: any): boolean {
return isDeviceActionTool(tool);
}
@@ -33,16 +33,38 @@
<header class="window-header" (mousedown)="startDrag($event)">
<div class="header-left">
<span class="material-symbols-outlined header-icon">
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display') }}
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
</span>
<span class="header-title" [title]="agentService.activeVideoTitle()">
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording')) }}
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
</span>
</div>
<div class="header-actions" (mousedown)="$event.stopPropagation()">
<!-- Playback Speed Pills (Shown only when playback video is active) -->
<div class="speed-pill-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoMinimized() && !videoLoadError() && agentService.activeVideoUrl()">
<!-- Mode Toggle Group: Video vs. Step Replay -->
<div class="mode-toggle-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()">
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'video'"
(click)="agentService.setPlayerMode('video')"
title="Video Recording"
>
<span class="material-symbols-outlined mode-icon">videocam</span>
<span>Video</span>
</button>
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'steps'"
(click)="agentService.setPlayerMode('steps')"
title="Step Screenshot Replay"
>
<span class="material-symbols-outlined mode-icon">slideshow</span>
<span>Steps</span>
</button>
</div>
<!-- Playback Speed Pills (Shown when video is active OR when step replay is active) -->
<div class="speed-pill-group" *ngIf="!agentService.isVideoMinimized() && (agentService.recordingPlaybackStatus() === 'ready' || agentService.playerMode() === 'steps') && (agentService.activeVideoUrl() || totalStepFrames() > 0)">
<button
*ngFor="let speed of speedOptions"
class="speed-btn"
@@ -121,16 +143,107 @@
</div>
</ng-container>
<!-- 2. RECORDING PLAYBACK (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
<ng-container *ngIf="agentService.recordingPlaybackStatus() !== 'live'">
<!-- Loading State -->
<div class="video-loading-state" *ngIf="agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading()">
<!-- Loading State (when in video mode and processing) -->
<div class="video-loading-state" *ngIf="agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())">
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
</div>
<!-- Active Video Playback View -->
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
<ng-container *ngIf="agentService.playerMode() === 'steps' && totalStepFrames() > 0">
<div class="step-replay-container">
<!-- Fallback info banner if recording had failed -->
<div class="step-replay-notice" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()">
<span class="material-symbols-outlined notice-icon">info</span>
<span>Screen recording unavailable. Replaying step screenshots.</span>
</div>
<div class="step-image-viewport">
<img
class="step-frame-img"
[src]="currentStepFrame()?.imageUrl"
[alt]="currentStepFrame()?.title"
(click)="openImageInNewTab()"
title="Click to view image in new tab"
/>
<!-- Step Overlay Badge -->
<div class="step-frame-badge" *ngIf="currentStepFrame() as frame">
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
<span class="badge-action-desc">{{ frame.actionText || frame.title }}</span>
</div>
</div>
<!-- Step Replay Controls Footer -->
<div class="video-controls-footer">
<!-- Timeline Scrubber Bar -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="totalStepFrames() - 1"
[step]="1"
[value]="activeStepIndex()"
(input)="onStepScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
</div>
<!-- Bottom Controls Row -->
<div class="controls-row">
<div class="controls-left">
<!-- Play / Pause Button -->
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
</button>
<!-- Previous Frame Button -->
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Frame">
<span class="material-symbols-outlined">skip_previous</span>
</button>
<!-- Next Frame Button -->
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Frame">
<span class="material-symbols-outlined">skip_next</span>
</button>
<!-- Step Replay / Reset -->
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
<span class="material-symbols-outlined">replay</span>
</button>
<!-- Frame Counter Display -->
<span class="time-display">
Frame {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
</span>
</div>
<div class="controls-right">
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
</button>
<!-- Open in new tab -->
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
<span class="material-symbols-outlined">open_in_new</span>
</button>
</div>
</div>
</div>
</div>
</ng-container>
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
<ng-container *ngIf="agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
<div class="video-container" *ngIf="!videoLoadError()">
<video
#videoRef
@@ -146,7 +259,7 @@
></video>
</div>
<!-- Dedicated Bottom Controls Bar (Outside Video: ZERO Obscurity / Zero Overlap) -->
<!-- Dedicated Bottom Controls Bar -->
<div class="video-controls-footer" *ngIf="!videoLoadError()">
<!-- Timeline Scrubber Bar -->
<div class="scrubber-bar-container">
@@ -206,8 +319,8 @@
</div>
</ng-container>
<!-- Clean, High-Contrast Minimal Empty / Fallback State -->
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError())">
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0">
<div class="fallback-icon-wrap">
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
</div>
@@ -176,6 +176,48 @@
gap: 4px;
flex-shrink: 0;
.mode-toggle-group {
display: flex;
align-items: center;
background: rgba(241, 245, 249, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
padding: 2px;
margin-right: 4px;
gap: 2px;
.mode-btn {
display: inline-flex;
align-items: center;
gap: 3px;
border: none;
background: transparent;
color: #475569;
font-size: 11px;
font-weight: 600;
padding: 2px 7px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
.mode-icon {
font-size: 13px;
}
&:hover:not(.active) {
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
}
&.active {
background: #0284c7;
color: #ffffff;
box-shadow: 0 1px 3px rgba(2, 132, 199, 0.3);
}
}
}
.speed-pill-group {
display: flex;
align-items: center;
@@ -185,8 +227,6 @@
border-radius: 8px;
padding: 2px;
margin-right: 4px;
padding: 2px;
margin-right: 4px;
.speed-btn {
border: none;
@@ -370,6 +410,98 @@
}
}
.step-replay-container {
display: flex;
flex-direction: column;
background: #000000;
position: relative;
width: 100%;
overflow: hidden;
.step-replay-notice {
display: flex;
align-items: center;
gap: 6px;
background: rgba(254, 243, 199, 0.95);
color: #92400e;
padding: 6px 12px;
font-size: 11.5px;
font-weight: 500;
border-bottom: 1px solid rgba(245, 158, 11, 0.3);
.notice-icon {
font-size: 14px;
color: #d97706;
flex-shrink: 0;
}
}
.step-image-viewport {
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: #0a0a0c;
min-height: 240px;
max-height: 520px;
cursor: pointer;
overflow: hidden;
.step-frame-img {
max-width: 100%;
max-height: 520px;
width: auto;
height: auto;
object-fit: contain;
display: block;
user-select: none;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.96;
}
}
.step-frame-badge {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 23, 42, 0.84);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
padding: 6px 10px;
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
pointer-events: none;
animation: fadeIn 0.2s ease;
.badge-step-no {
font-size: 11px;
font-weight: 700;
background: #0284c7;
padding: 1px 6px;
border-radius: 4px;
letter-spacing: 0.2px;
flex-shrink: 0;
}
.badge-action-desc {
font-size: 11.5px;
font-weight: 500;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.video-container {
position: relative;
width: 100%;
@@ -22,11 +22,13 @@ import {
ElementRef,
ViewChild,
HostListener,
effect
effect,
OnDestroy
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
@Component({
selector: 'app-floating-video-player',
@@ -35,7 +37,7 @@ import { AgentService } from '../../services/agent.service';
templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss'
})
export class FloatingVideoPlayerComponent {
export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@@ -60,6 +62,24 @@ export class FloatingVideoPlayerComponent {
private pendingAutoplay = false;
private pendingAbsoluteSeek: number | null = null;
// Step Replay state signals
public activeStepIndex = signal<number>(0);
public isStepPlaying = signal<boolean>(false);
private stepTimer: any = null;
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
public totalStepFrames = computed(() => this.stepFrames().length);
public currentStepFrame = computed<StepReplayFrame | null>(() => {
const frames = this.stepFrames();
const idx = this.activeStepIndex();
return frames[idx] || null;
});
public stepProgressPercent = computed(() => {
const total = this.totalStepFrames();
if (total <= 1) return 0;
return (this.activeStepIndex() / (total - 1)) * 100;
});
// Available speed options
public speedOptions = [0.5, 1.0, 1.5, 2.0, 4.0];
@@ -92,6 +112,16 @@ export class FloatingVideoPlayerComponent {
{ allowSignalWrites: true }
);
// Reset step playback state when session changes
effect(
() => {
const _ = this.stepFrames();
this.activeStepIndex.set(0);
this.pauseStepPlay();
},
{ allowSignalWrites: true }
);
effect(() => {
const request = this.agentService.videoSeekRequest();
if (!request) return;
@@ -327,6 +357,9 @@ export class FloatingVideoPlayerComponent {
if (v) {
v.playbackRate = rate;
}
if (this.isStepPlaying()) {
this.scheduleNextStepFrame();
}
}
public toggleLoop(): void {
@@ -361,4 +394,89 @@ export class FloatingVideoPlayerComponent {
window.open(url, '_blank');
}
}
/**
* Step Replay Controls
*/
public toggleStepPlay(): void {
if (this.isStepPlaying()) {
this.pauseStepPlay();
} else {
this.startStepPlay();
}
}
public startStepPlay(): void {
if (this.totalStepFrames() <= 0) return;
if (this.activeStepIndex() >= this.totalStepFrames() - 1) {
this.activeStepIndex.set(0);
}
this.isStepPlaying.set(true);
this.scheduleNextStepFrame();
}
public pauseStepPlay(): void {
this.isStepPlaying.set(false);
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
}
private scheduleNextStepFrame(): void {
if (this.stepTimer) clearTimeout(this.stepTimer);
const delay = Math.max(300, 1500 / this.playbackRate());
this.stepTimer = setTimeout(() => {
if (!this.isStepPlaying()) return;
if (this.activeStepIndex() < this.totalStepFrames() - 1) {
this.activeStepIndex.update((i) => i + 1);
this.scheduleNextStepFrame();
} else {
if (this.isLooping()) {
this.activeStepIndex.set(0);
this.scheduleNextStepFrame();
} else {
this.pauseStepPlay();
}
}
}, delay);
}
public prevStepFrame(): void {
this.pauseStepPlay();
this.activeStepIndex.update((i) => Math.max(0, i - 1));
}
public nextStepFrame(): void {
this.pauseStepPlay();
this.activeStepIndex.update((i) => Math.min(this.totalStepFrames() - 1, i + 1));
}
public seekStepFrame(index: number): void {
this.activeStepIndex.set(Math.max(0, Math.min(this.totalStepFrames() - 1, index)));
}
public onStepScrub(event: Event): void {
const input = event.target as HTMLInputElement;
this.seekStepFrame(parseInt(input.value, 10));
}
public restartStep(): void {
this.seekStepFrame(0);
this.startStepPlay();
}
public openImageInNewTab(): void {
const url = this.currentStepFrame()?.imageUrl;
if (url) {
window.open(url, '_blank');
}
}
public ngOnDestroy(): void {
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
}
}