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
@@ -6,11 +6,29 @@ 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" },
const SHORTCUT_SECTIONS = [
{
title: "Playback",
hints: [
{ key: "Space", label: "Play / Pause" },
{ key: "J", label: "Play backward" },
{ key: "K", label: "Stop" },
{ key: "L", label: "Play forward" },
{ key: "←/→", label: "Step 1 frame" },
{ key: "⇧←/⇧→", label: "Step 10 frames" },
],
},
{
title: "Work area",
hints: [
{ key: "I", label: "Set in-point" },
{ key: "⇧I", label: "Clear in-point" },
{ key: "O", label: "Set out-point" },
{ key: "⇧O", label: "Clear out-point" },
{ key: "A", label: "Jump to in-point" },
{ key: "E", label: "Jump to out-point" },
],
},
] as const;
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
@@ -42,7 +60,12 @@ export const PlayerControls = memo(function PlayerControls({
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
const inPoint = usePlayerStore((s) => s.inPoint);
const outPoint = usePlayerStore((s) => s.outPoint);
const setInPoint = usePlayerStore.getState().setInPoint;
const setOutPoint = usePlayerStore.getState().setOutPoint;
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const [jumpFrame, setJumpFrame] = useState("");
@@ -52,6 +75,7 @@ export const PlayerControls = memo(function PlayerControls({
const seekBarRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
const shortcutsPanelRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
const timeDisplayModeRef = useRef(timeDisplayMode);
@@ -116,6 +140,19 @@ export const PlayerControls = memo(function PlayerControls({
};
}, [showSpeedMenu]);
useEffect(() => {
if (!showShortcuts) return;
const handleMouseDown = (e: MouseEvent) => {
if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
setShowShortcuts(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showShortcuts]);
const seekFromClientX = useCallback(
(clientX: number) => {
if (disabled) return;
@@ -278,10 +315,14 @@ export const PlayerControls = memo(function PlayerControls({
)}
</button>
{/* Time display */}
<span
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
style={{ color: "#A1A1AA" }}
{/* Time display — click to toggle time/frame mode */}
<button
type="button"
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
disabled={disabled}
title={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
style={{ color: "#A1A1AA", cursor: "pointer" }}
>
<span ref={timeDisplayRef}>{formatTime(0)}</span>
{timeDisplayMode === "time" ? (
@@ -290,7 +331,7 @@ export const PlayerControls = memo(function PlayerControls({
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
</>
) : null}
</span>
</button>
{/* Seek bar — teal progress fill */}
<div
@@ -320,16 +361,57 @@ export const PlayerControls = memo(function PlayerControls({
className="w-full rounded-full relative"
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
>
{/* Work-area band between in/out points */}
{(inPoint !== null || outPoint !== null) && duration > 0 && (
<div
className="absolute top-0 bottom-0 pointer-events-none"
style={{
left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
background: "rgba(60,230,172,0.15)",
}}
/>
)}
{/* 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)" }}
/>
{/* In-point marker */}
{inPoint !== null && duration > 0 && (
<div
className="absolute z-[3] pointer-events-none"
style={{
left: `${Math.min(100, (inPoint / duration) * 100)}%`,
top: "50%",
transform: "translate(-50%, -50%)",
width: "2px",
height: "10px",
background: "#3CE6AC",
borderRadius: "1px",
}}
/>
)}
{/* Out-point marker */}
{outPoint !== null && duration > 0 && (
<div
className="absolute z-[3] pointer-events-none"
style={{
left: `${Math.min(100, (outPoint / duration) * 100)}%`,
top: "50%",
transform: "translate(-50%, -50%)",
width: "2px",
height: "10px",
background: "#3CE6AC",
borderRadius: "1px",
}}
/>
)}
{/* 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"
className="absolute top-1/2 z-[4] 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)",
@@ -385,7 +467,7 @@ export const PlayerControls = memo(function PlayerControls({
type="button"
onClick={() => setLoopEnabled(!loopEnabled)}
disabled={disabled}
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
className={`h-7 w-7 flex items-center justify-center rounded-md border 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"
@@ -394,53 +476,201 @@ export const PlayerControls = memo(function PlayerControls({
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
aria-pressed={loopEnabled}
>
Loop
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</button>
<button
type="button"
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
disabled={disabled}
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)}
disabled={disabled}
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"
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
<button
type="button"
onClick={() => setShowShortcuts((v) => !v)}
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
showShortcuts
? "border-neutral-600 text-neutral-200 bg-neutral-800"
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
}`}
aria-label="Shortcuts and tools"
aria-expanded={showShortcuts}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{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>
))}
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
</svg>
</button>
{showShortcuts && (
<div
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
style={{
background: "#161618",
border: "1px solid rgba(255,255,255,0.08)",
maxHeight: "min(280px, calc(100vh - 80px))",
}}
>
{/* Frame jump */}
<div className="px-3 pt-3 pb-2.5">
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
Jump to frame
</p>
<form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
<input
value={jumpFrame}
onChange={(e) => setJumpFrame(e.target.value)}
disabled={disabled}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Jump to frame"
placeholder="frame number"
className="h-6 flex-1 rounded 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}
/>
<button
type="submit"
disabled={disabled}
className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
>
Go
</button>
</form>
</div>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
{/* Work area */}
<div className="px-3 pt-2.5 pb-2">
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
Work area
</p>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
style={{ background: "rgba(255,255,255,0.05)" }}
>
I
</span>
<span className="text-[10px] text-neutral-400">In-point</span>
</div>
<div className="flex items-center gap-1.5">
{inPoint !== null ? (
<>
<span className="font-mono text-[10px] text-neutral-300">
{formatTime(inPoint)}
</span>
<button
type="button"
onClick={() => setInPoint(null)}
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
aria-label="Clear in-point"
>
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</>
) : (
<span className="text-[10px] text-neutral-600"></span>
)}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
style={{ background: "rgba(255,255,255,0.05)" }}
>
O
</span>
<span className="text-[10px] text-neutral-400">Out-point</span>
</div>
<div className="flex items-center gap-1.5">
{outPoint !== null ? (
<>
<span className="font-mono text-[10px] text-neutral-300">
{formatTime(outPoint)}
</span>
<button
type="button"
onClick={() => setOutPoint(null)}
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
aria-label="Clear out-point"
>
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</>
) : (
<span className="text-[10px] text-neutral-600"></span>
)}
</div>
</div>
</div>
</div>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
{/* Shortcuts */}
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
{SHORTCUT_SECTIONS.map((section) => (
<div key={section.title}>
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
{section.title}
</p>
<div className="flex flex-col gap-1">
{section.hints.map((hint) => (
<div key={hint.key} className="flex items-center gap-3">
<span
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
style={{ background: "rgba(255,255,255,0.05)" }}
>
{hint.key}
</span>
<span className="text-[10px] text-neutral-400">{hint.label}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
@@ -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;
@@ -43,6 +43,10 @@ interface PlayerState {
zoomMode: ZoomMode;
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
inPoint: number | null;
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
outPoint: number | null;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
@@ -58,6 +62,8 @@ interface PlayerState {
) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
setInPoint: (time: number | null) => void;
setOutPoint: (time: number | null) => void;
reset: () => void;
/**
@@ -93,6 +99,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
inPoint: null,
outPoint: null,
requestedSeekTime: null,
requestSeek: (time) => set({ requestedSeekTime: time }),
@@ -105,6 +113,23 @@ export const usePlayerStore = create<PlayerState>((set) => ({
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
setInPoint: (time) =>
set((state) => {
const t = time !== null && Number.isFinite(time) ? time : null;
return {
inPoint: t,
outPoint:
t !== null && state.outPoint !== null && t >= state.outPoint ? null : state.outPoint,
};
}),
setOutPoint: (time) =>
set((state) => {
const t = time !== null && Number.isFinite(time) ? time : null;
return {
outPoint: t,
inPoint: t !== null && state.inPoint !== null && t <= state.inPoint ? null : state.inPoint,
};
}),
setManualZoomPercent: (percent) =>
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
@@ -129,5 +154,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
timelineReady: false,
elements: [],
selectedElementId: null,
inPoint: null,
outPoint: null,
}),
}));