fix: keep Studio frame stepping advancing (#573)

## Problem

Closes #568.

Studio preview-focused frame stepping could stop advancing after a couple of ArrowLeft/ArrowRight presses. The same integer-frame stepping path also affected the K-held J/L one-frame shuttle controls.

## What this fixes

- Adds a shared `stepFrameTime` helper that advances by integer frame index instead of adding fractional seconds.
- Uses that helper for preview-surface keyboard shortcuts and the focused seek slider.
- Adds regression coverage for truncated runtime times like `0.0333333`, which previously stepped back onto the same frame.

## Root cause

The runtime seek path quantizes requested times with `Math.floor(time * fps)`. Studio was deriving the next frame from the runtime's current seconds value, which can be a truncated decimal such as `0.0333333`. Adding `1 / 30` to that value can produce `1.999998...` frames, so floor-quantization lands back on the previous frame and repeated shortcuts appear to stop responding.

## Verification

### Local checks

- `bun run --filter @hyperframes/core build:hyperframes-runtime`
- `bun run --filter @hyperframes/studio test -- src/player/lib/time.test.ts src/player/hooks/useTimelinePlayer.test.ts src/player/components/PlayerControls.test.ts`
- `bunx oxfmt --check packages/studio/src/player/lib/time.ts packages/studio/src/player/lib/time.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/components/PlayerControls.tsx`
- `bunx oxlint packages/studio/src/player/lib/time.ts packages/studio/src/player/lib/time.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/components/PlayerControls.tsx`
- `bun run --filter @hyperframes/studio typecheck`
- `bun run --filter @hyperframes/studio build`
- `git diff --check`
- Lefthook pre-commit: lint, format, typecheck
- Lefthook commit-msg: commitlint

### Browser verification

- Created `/tmp/hf-studio-frame-step-repro` with a 10s GSAP animation.
- Started Studio preview at `http://localhost:5191/#project/hf-studio-frame-step-repro`.
- Used `agent-browser` to reproduce the original stuck behavior before the fix: repeated preview-focused `ArrowRight` keydowns were handled but runtime time stayed at `0.0333333`.
- Used `agent-browser` after the fix to verify preview-focused `ArrowRight` advances 10 frames to `0.3333333`.
- Used `agent-browser` to verify K-held L steps forward 5 frames to `0.1666667` and K-held J steps backward from 5 frames to `0`.
- Used actual Safari 18.6 with System Events key presses to verify 10 and then 20 preview-focused ArrowRight presses continue advancing visually.

## Notes

- Safari WebDriver was unavailable because Safari's "Allow remote automation" setting is disabled on this machine, so the Safari check used real Safari GUI key events instead.
- Local proof artifacts are intentionally not committed:
  - `qa-artifacts/studio-frame-step-issue-568/chrome-after-10-arrow-right.png`
  - `qa-artifacts/studio-frame-step-issue-568/chrome-frame-step-flow.webm`
  - `qa-artifacts/studio-frame-step-issue-568/safari-after-10-arrow-right.png`
  - `qa-artifacts/studio-frame-step-issue-568/safari-after-20-arrow-right.png`
This commit is contained in:
Miguel Ángel
2026-04-30 03:03:17 +02:00
committed by GitHub
parent 22f0e6a5cd
commit 3f6907e807
4 changed files with 22 additions and 6 deletions
@@ -1,6 +1,6 @@
import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -208,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowLeft") {
e.preventDefault();
onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
onSeek(stepFrameTime(currentTimeRef.current, -step));
} else if (e.key === "ArrowRight") {
e.preventDefault();
onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
}
},
[timelineReady, duration, onSeek],