From 3f6907e807b70af23162baee9e4f5bffb41c407e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 30 Apr 2026 03:03:17 +0200 Subject: [PATCH] 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` --- .../studio/src/player/components/PlayerControls.tsx | 6 +++--- .../studio/src/player/hooks/useTimelinePlayer.ts | 4 ++-- packages/studio/src/player/lib/time.test.ts | 12 +++++++++++- packages/studio/src/player/lib/time.ts | 6 ++++++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index b9d6deede..a685059dd 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -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], diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 9947b1d63..9a379fbba 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -1,7 +1,7 @@ import { useRef, useCallback } from "react"; import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; import { useMountEffect } from "../../hooks/useMountEffect"; -import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time"; +import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time"; import { useCaptionStore } from "../../captions/store"; interface PlaybackAdapter { @@ -697,7 +697,7 @@ export function useTimelinePlayer() { (deltaFrames: number) => { const adapter = getAdapter(); const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime; - seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS)); + seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS)); }, [getAdapter, seek], ); diff --git a/packages/studio/src/player/lib/time.test.ts b/packages/studio/src/player/lib/time.test.ts index e61c3b756..3efb20a21 100644 --- a/packages/studio/src/player/lib/time.test.ts +++ b/packages/studio/src/player/lib/time.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time"; +import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time"; describe("formatTime", () => { it("formats zero seconds", () => { @@ -72,4 +72,14 @@ describe("frame helpers", () => { it("formats current and total frame display", () => { expect(formatFrameTime(1, 5)).toBe("30f / 150f"); }); + + it("steps from a truncated runtime time by integer frame index", () => { + expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30); + expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30); + expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30); + }); + + it("clamps frame stepping at zero", () => { + expect(stepFrameTime(0, -1)).toBe(0); + }); }); diff --git a/packages/studio/src/player/lib/time.ts b/packages/studio/src/player/lib/time.ts index 83099c721..0f90e8467 100644 --- a/packages/studio/src/player/lib/time.ts +++ b/packages/studio/src/player/lib/time.ts @@ -19,6 +19,12 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number return frame / fps; } +export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number { + const currentFrame = secondsToFrame(time, fps); + const nextFrame = Math.max(0, currentFrame + deltaFrames); + return frameToSeconds(nextFrame, fps); +} + export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string { const currentFrame = secondsToFrame(time, fps); const totalFrames = secondsToFrame(duration, fps);