feat(studio): header logo, playbar cleanup, and I/O work-area markers (#811)

* feat(studio): header logo, playbar cleanup, and I/O work-area markers

- Add Hyperframes icon mark to the studio header (left of project name)
- Remove m:ss toggle button — click the timecode directly to switch modes
- Remove frame jump input from controls bar — moved into ⌨ shortcuts panel
- Replace Loop text button with a repeat icon
- Collapse J/K/L shortcut badges into a single ⌨ icon that opens a panel
- Shortcuts panel: Jump to frame, Work area I/O display, shortcuts reference
- Implement I/O work-area markers (closes #807):
  - I / Shift+I: set / clear in-point at playhead
  - O / Shift+O: set / clear out-point at playhead
  - A: jump to in-point (or start); E: jump to out-point (or end)
  - Loop respects in/out boundaries for both forward and backward playback
  - Teal work-area band + tick markers rendered on the seek bar

* fix(studio): guard against inverted in/out work-area points in loop ticks

If the user sets out-point before in-point (outPoint < inPoint), rawLoopStart
>= rawLoopEnd caused the loop guard to fire immediately on every tick, creating
a tight infinite seek loop. Both the forward RAF tick and the reverse RAF tick
now fall back to the full composition range when the work area is invalid.

* fix(studio): address work-area edge cases from review

- setInPoint/setOutPoint now cross-clear the opposite marker when setting one
  would produce an inverted range (in >= out), preventing the invalid state
  rather than correcting it at tick time
- Forward tick no longer gates on !adapter.isPlaying() — outPoint crossing
  fires even while the adapter is running; explicitly pauses on the non-loop
  path so playback stops at out-point rather than sailing to dur
- play() end-of-stream reset seeks to inPoint (if set) instead of hardcoded 0

* feat(studio): use full Hyperframes wordmark logo in header

Replace the standalone icon mark with the complete logo from logo-dark.svg
(icon mark + Hyperframes wordmark), with all black text fills inverted to
white for the dark header background. Project name is shown next to the logo
separated by a middot.

* Revert "feat(studio): use full Hyperframes wordmark logo in header"

This reverts commit a2815fc7d0.

* feat(studio): show full HeyGen/Hyperframes logo in header

Replace the standalone chevron icon with the complete logo from logo-dark.svg:
heygen label + gradient mark + hyperframes wordmark, all white fills on dark
background. Project name follows after a middot separator.

* fix(studio): use | instead of · as logo/project separator
This commit is contained in:
Miguel Ángel
2026-05-14 01:06:55 +02:00
committed by GitHub
parent c08e8b2322
commit 1caeb28658
6 changed files with 488 additions and 70 deletions
@@ -128,9 +128,33 @@ export function usePlaybackKeyboard({
return;
}
shuttle("forward");
return;
}
if (e.code === "KeyI") {
e.preventDefault();
const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
usePlayerStore.getState().setInPoint(e.shiftKey ? null : t);
return;
}
if (e.code === "KeyO") {
e.preventDefault();
const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
usePlayerStore.getState().setOutPoint(e.shiftKey ? null : t);
return;
}
if (e.code === "KeyA") {
e.preventDefault();
seek(usePlayerStore.getState().inPoint ?? 0);
return;
}
if (e.code === "KeyE") {
e.preventDefault();
const { outPoint } = usePlayerStore.getState();
seek(outPoint ?? getAdapter()?.getDuration() ?? usePlayerStore.getState().duration);
return;
}
},
[pause, shuttle, stepFrames, togglePlay],
[pause, shuttle, stepFrames, togglePlay, getAdapter, seek],
);
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
@@ -185,15 +185,21 @@ export function useTimelinePlayer() {
const time = adapter.getTime();
const dur = adapter.getDuration();
liveTime.notify(time); // direct DOM updates, no React re-render
if (time >= dur && !adapter.isPlaying()) {
const { inPoint, outPoint } = usePlayerStore.getState();
const rawLoopEnd = outPoint !== null ? outPoint : dur;
const rawLoopStart = inPoint !== null ? inPoint : 0;
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
if (time >= loopEnd) {
if (usePlayerStore.getState().loopEnabled && dur > 0) {
adapter.seek(0);
liveTime.notify(0);
adapter.seek(loopStart);
liveTime.notify(loopStart);
adapter.play();
setIsPlaying(true);
rafRef.current = requestAnimationFrame(tick);
return;
}
if (adapter.isPlaying()) adapter.pause();
setCurrentTime(time); // sync Zustand once at end
setIsPlaying(false);
cancelAnimationFrame(rafRef.current);
@@ -241,7 +247,7 @@ export function useTimelinePlayer() {
const adapter = getAdapter();
if (!adapter) return;
if (adapter.getTime() >= adapter.getDuration()) {
adapter.seek(0);
adapter.seek(usePlayerStore.getState().inPoint ?? 0);
}
unmutePreviewMedia(iframeRef.current);
applyPlaybackRate(usePlayerStore.getState().playbackRate);
@@ -269,15 +275,20 @@ export function useTimelinePlayer() {
const tick = (now: number) => {
const elapsed = ((now - startedAt) / 1000) * speed;
let nextTime = startTime - elapsed;
if (nextTime <= 0) {
const { inPoint, outPoint } = usePlayerStore.getState();
const rawLoopEnd = outPoint !== null ? outPoint : duration;
const rawLoopStart = inPoint !== null ? inPoint : 0;
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : duration;
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
if (nextTime <= loopStart) {
if (usePlayerStore.getState().loopEnabled && duration > 0) {
startTime = duration;
startTime = loopEnd;
startedAt = now;
nextTime = duration;
nextTime = loopEnd;
} else {
adapter.seek(0);
liveTime.notify(0);
setCurrentTime(0);
adapter.seek(loopStart);
liveTime.notify(loopStart);
setCurrentTime(loopStart);
setIsPlaying(false);
shuttleDirectionRef.current = null;
reverseRafRef.current = 0;