mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user