mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## 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`
435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
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;
|
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
type TimeDisplayMode = "time" | "frame";
|
|
const SHORTCUT_HINTS = [
|
|
{ key: "J", label: "Play backward" },
|
|
{ key: "K", label: "Stop playback" },
|
|
{ key: "L", label: "Play forward" },
|
|
{ key: "←/→", label: "Step one frame backward or forward" },
|
|
] as const;
|
|
|
|
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
|
|
const rawPercent = (clientX - rectLeft) / rectWidth;
|
|
const clamped = Math.max(0, Math.min(1, rawPercent));
|
|
const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
|
|
if (clamped <= snapThreshold) return 0;
|
|
if (clamped >= 1 - snapThreshold) return 1;
|
|
return clamped;
|
|
}
|
|
|
|
interface PlayerControlsProps {
|
|
onTogglePlay: () => void;
|
|
onSeek: (time: number) => void;
|
|
}
|
|
|
|
export const PlayerControls = memo(function PlayerControls({
|
|
onTogglePlay,
|
|
onSeek,
|
|
}: PlayerControlsProps) {
|
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
const duration = usePlayerStore((s) => s.duration);
|
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
|
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
|
|
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
|
const seekBarRef = useRef<HTMLDivElement>(null);
|
|
const sliderRef = useRef<HTMLDivElement>(null);
|
|
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
|
const isDraggingRef = useRef(false);
|
|
const currentTimeRef = useRef(0);
|
|
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
timeDisplayModeRef.current = timeDisplayMode;
|
|
|
|
const durationRef = useRef(duration);
|
|
durationRef.current = duration;
|
|
useMountEffect(() => {
|
|
const updateProgress = (t: number) => {
|
|
currentTimeRef.current = t;
|
|
const dur = durationRef.current;
|
|
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
|
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
if (timeDisplayRef.current) {
|
|
timeDisplayRef.current.textContent =
|
|
timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
|
|
}
|
|
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
|
|
};
|
|
const unsub = liveTime.subscribe(updateProgress);
|
|
updateProgress(usePlayerStore.getState().currentTime);
|
|
|
|
// Also poll every 500ms as a fallback in case liveTime doesn't fire
|
|
const interval = setInterval(() => {
|
|
const t = usePlayerStore.getState().currentTime;
|
|
const dur = usePlayerStore.getState().duration;
|
|
if (dur > 0 && t > 0) {
|
|
const pct = Math.min(100, (t / dur) * 100);
|
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
}
|
|
}, 500);
|
|
|
|
return () => {
|
|
unsub();
|
|
clearInterval(interval);
|
|
};
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!timeDisplayRef.current) return;
|
|
const t = currentTimeRef.current;
|
|
timeDisplayRef.current.textContent =
|
|
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
|
|
}, [duration, timeDisplayMode]);
|
|
|
|
useEffect(() => {
|
|
if (!showSpeedMenu) return;
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
if (
|
|
speedMenuContainerRef.current &&
|
|
!speedMenuContainerRef.current.contains(e.target as Node)
|
|
) {
|
|
setShowSpeedMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleMouseDown);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleMouseDown);
|
|
};
|
|
}, [showSpeedMenu]);
|
|
|
|
const seekFromClientX = useCallback(
|
|
(clientX: number) => {
|
|
const bar = seekBarRef.current;
|
|
if (!bar || duration <= 0) return;
|
|
const rect = bar.getBoundingClientRect();
|
|
const percent = resolveSeekPercent(clientX, rect.left, rect.width);
|
|
// Immediately update progress bar visuals (don't wait for liveTime round-trip)
|
|
const pct = percent * 100;
|
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
onSeek(percent * duration);
|
|
},
|
|
[duration, onSeek],
|
|
);
|
|
|
|
const handlePointerDown = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
// Ignore secondary mouse buttons — only primary (left click / touch /
|
|
// pen contact) should start a drag.
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
// preventDefault() on pointerdown also suppresses the implicit focus
|
|
// transfer that click normally grants a `tabIndex=0` element — which
|
|
// matches native `<input type="range">` behavior, but it also means a
|
|
// click-then-arrow-key workflow wouldn't work. Restore focus explicitly
|
|
// so seeking by click and nudging by arrow keys compose naturally.
|
|
e.currentTarget.focus();
|
|
isDraggingRef.current = true;
|
|
|
|
// `setPointerCapture` routes every subsequent pointermove/up to the
|
|
// slider element even when the pointer leaves its bounding box. Without
|
|
// it, fast drags on touch would lose events the moment the finger
|
|
// slips outside the 6 px-tall hit zone.
|
|
const target = e.currentTarget;
|
|
const pointerId = e.pointerId;
|
|
try {
|
|
target.setPointerCapture(pointerId);
|
|
} catch {
|
|
/* non-supporting browsers fall back to window listeners below */
|
|
}
|
|
|
|
seekFromClientX(e.clientX);
|
|
|
|
const onMove = (ev: PointerEvent) => {
|
|
if (ev.pointerId !== pointerId) return;
|
|
if (isDraggingRef.current) seekFromClientX(ev.clientX);
|
|
};
|
|
const cleanup = () => {
|
|
isDraggingRef.current = false;
|
|
try {
|
|
target.releasePointerCapture(pointerId);
|
|
} catch {
|
|
/* Already released after the first cleanup — second invocation
|
|
via the window-fallback or visibility path is a no-op throw. */
|
|
}
|
|
target.removeEventListener("pointermove", onMove);
|
|
target.removeEventListener("pointerup", onUp);
|
|
target.removeEventListener("pointercancel", onUp);
|
|
window.removeEventListener("pointerup", onUp);
|
|
window.removeEventListener("pointercancel", onUp);
|
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
window.removeEventListener("blur", cleanup);
|
|
};
|
|
const onUp = (ev: PointerEvent) => {
|
|
if (ev.pointerId !== pointerId) return;
|
|
cleanup();
|
|
};
|
|
// iOS Safari does not reliably fire `pointercancel` when the page is
|
|
// backgrounded mid-drag (alt-tab, incoming call, switch apps). Without
|
|
// a release path the ref stays `true` until the next pointerdown — a
|
|
// stuck-scrubber class bug waiting to happen if anyone later gates
|
|
// rendering on `isDragging`. Synthesize the release on hide / blur.
|
|
const onVisibilityChange = () => {
|
|
if (document.visibilityState === "hidden") cleanup();
|
|
};
|
|
|
|
target.addEventListener("pointermove", onMove);
|
|
target.addEventListener("pointerup", onUp);
|
|
target.addEventListener("pointercancel", onUp);
|
|
// Window-level fallback in case capture fails and the pointer release
|
|
// lands outside the element (rare, but defensive).
|
|
window.addEventListener("pointerup", onUp);
|
|
window.addEventListener("pointercancel", onUp);
|
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
window.addEventListener("blur", cleanup);
|
|
},
|
|
[seekFromClientX],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (!timelineReady || duration <= 0) return;
|
|
const step = e.shiftKey ? 10 : 1;
|
|
if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
} else if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
|
}
|
|
},
|
|
[timelineReady, duration, onSeek],
|
|
);
|
|
|
|
const commitJumpFrame = useCallback(() => {
|
|
const frame = Number.parseInt(jumpFrame, 10);
|
|
if (!Number.isFinite(frame) || duration <= 0) return;
|
|
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
|
|
}, [duration, jumpFrame, onSeek]);
|
|
|
|
const handleJumpSubmit = useCallback(
|
|
(e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
commitJumpFrame();
|
|
},
|
|
[commitJumpFrame],
|
|
);
|
|
|
|
const handleJumpKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key !== "Enter") return;
|
|
e.preventDefault();
|
|
commitJumpFrame();
|
|
},
|
|
[commitJumpFrame],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
style={{
|
|
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
|
|
// the Play button + timecode on iPhone. `env(safe-area-inset-bottom)`
|
|
// is 0 everywhere else, so this is a no-op on desktop.
|
|
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
|
|
}}
|
|
>
|
|
{/* Play/Pause button */}
|
|
<button
|
|
type="button"
|
|
aria-label={isPlaying ? "Pause" : "Play"}
|
|
onClick={onTogglePlay}
|
|
disabled={!timelineReady}
|
|
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
|
style={{ background: "rgba(255,255,255,0.06)" }}
|
|
>
|
|
{isPlaying ? (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
|
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
</svg>
|
|
) : (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
|
<polygon points="6,3 20,12 6,21" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Time display */}
|
|
<span
|
|
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
|
|
style={{ color: "#A1A1AA" }}
|
|
>
|
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
{timeDisplayMode === "time" ? (
|
|
<>
|
|
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
|
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
</>
|
|
) : null}
|
|
</span>
|
|
|
|
{/* Seek bar — teal progress fill */}
|
|
<div
|
|
ref={(el) => {
|
|
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
}}
|
|
role="slider"
|
|
tabIndex={0}
|
|
aria-label="Seek"
|
|
aria-valuemin={0}
|
|
aria-valuemax={Math.round(duration)}
|
|
aria-valuenow={0}
|
|
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
|
|
// `touch-action: none` tells the browser we're handling every
|
|
// pointer gesture on this element ourselves. Without it, iOS
|
|
// Safari consumes horizontal swipes for its own swipe-back-to-
|
|
// previous-page navigation and the scrubber can't drag left.
|
|
style={{ touchAction: "none" }}
|
|
onPointerDown={handlePointerDown}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<div
|
|
className="w-full rounded-full relative"
|
|
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
|
|
>
|
|
{/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
|
|
<div
|
|
ref={progressFillRef}
|
|
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
|
|
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
|
|
/>
|
|
{/* Playhead thumb — left is controlled imperatively via ref */}
|
|
<div
|
|
ref={progressThumbRef}
|
|
className="absolute top-1/2 z-[2] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
|
|
style={{
|
|
background: "var(--hf-accent, #3CE6AC)",
|
|
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Speed control */}
|
|
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
|
>
|
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
</button>
|
|
{showSpeedMenu && (
|
|
<div
|
|
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
|
|
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
|
|
>
|
|
{SPEED_OPTIONS.map((rate) => (
|
|
<button
|
|
key={rate}
|
|
onClick={() => {
|
|
setPlaybackRate(rate);
|
|
setShowSpeedMenu(false);
|
|
}}
|
|
className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
|
|
style={{
|
|
color: rate === playbackRate ? "#FAFAFA" : "#71717A",
|
|
background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (rate !== playbackRate)
|
|
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
|
|
}}
|
|
>
|
|
{rate}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setLoopEnabled(!loopEnabled)}
|
|
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
|
|
loopEnabled
|
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
}`}
|
|
title="Loop playback"
|
|
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
aria-pressed={loopEnabled}
|
|
>
|
|
Loop
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
|
|
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
|
title="Toggle time/frame display"
|
|
aria-label="Toggle time and frame display"
|
|
>
|
|
{timeDisplayMode === "time" ? "m:ss" : "frames"}
|
|
</button>
|
|
|
|
<form
|
|
onSubmit={handleJumpSubmit}
|
|
className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
|
|
>
|
|
<input
|
|
value={jumpFrame}
|
|
onChange={(e) => setJumpFrame(e.target.value)}
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
aria-label="Jump to frame"
|
|
placeholder="frame"
|
|
className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
|
|
onKeyDown={handleJumpKeyDown}
|
|
onBlur={commitJumpFrame}
|
|
/>
|
|
</form>
|
|
|
|
<div
|
|
className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
|
|
aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
|
|
>
|
|
{SHORTCUT_HINTS.map((shortcut) => (
|
|
<span
|
|
key={shortcut.key}
|
|
className="group relative rounded border border-neutral-800 px-1 py-0.5"
|
|
>
|
|
{shortcut.key}
|
|
<span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
|
|
{shortcut.label}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|