diff --git a/.filesize-allowlist b/.filesize-allowlist index 693d19295..a9261f39f 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -1,2 +1,3 @@ packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/hooks/useManifestPersistence.ts +packages/studio/src/player/components/PlayerControls.tsx diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 8315e1377..ee4c5ea66 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -18,6 +18,127 @@ export interface StudioHeaderProps { inspectorPanelActive: boolean; } +function HyperframesLogo() { + // Full logo from logo-dark.svg (263×79): heygen label + gradient mark + hyperframes wordmark. + // All fill="black" paths inverted to white for the dark header. + const height = 28; + const width = Math.round(height * (263 / 79)); + return ( + + + + + + + + + + + + {/* heygen label */} + + + + + + + {/* gradient icon mark */} + + + {/* hyperframes wordmark */} + + + + + + + + + + + + + ); +} + export function StudioHeader({ captureFrameHref, captureFrameFilename, @@ -32,9 +153,13 @@ export function StudioHeader({ return (
- {/* Left: project name */} -
- {projectId} + {/* Left: logo + project name */} +
+ + + {projectId}
{/* Right: toolbar buttons */}
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index ed4d85538..9594a91ba 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -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("time"); const [jumpFrame, setJumpFrame] = useState(""); @@ -52,6 +75,7 @@ export const PlayerControls = memo(function PlayerControls({ const seekBarRef = useRef(null); const sliderRef = useRef(null); const speedMenuContainerRef = useRef(null); + const shortcutsPanelRef = useRef(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({ )} - {/* Time display */} - 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" }} > {formatTime(0)} {timeDisplayMode === "time" ? ( @@ -290,7 +331,7 @@ export const PlayerControls = memo(function PlayerControls({ {formatTime(duration)} ) : null} - + {/* Seek bar — teal progress fill */}
+ {/* Work-area band between in/out points */} + {(inPoint !== null || outPoint !== null) && duration > 0 && ( +
+ )} {/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
+ {/* In-point marker */} + {inPoint !== null && duration > 0 && ( +
+ )} + {/* Out-point marker */} + {outPoint !== null && duration > 0 && ( +
+ )} {/* Playhead thumb — left is controlled imperatively via ref */}
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 - - - -
- 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} - /> -
- -
- {SHORTCUT_HINTS.map((shortcut) => ( - + + {showShortcuts && ( +
+ {/* Frame jump */} +
+

+ Jump to frame +

+
+ 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} + /> + +
+
+
+ {/* Work area */} +
+

+ Work area +

+
+
+
+ + I + + In-point +
+
+ {inPoint !== null ? ( + <> + + {formatTime(inPoint)} + + + + ) : ( + + )} +
+
+
+
+ + O + + Out-point +
+
+ {outPoint !== null ? ( + <> + + {formatTime(outPoint)} + + + + ) : ( + + )} +
+
+
+
+
+ {/* Shortcuts */} +
+ {SHORTCUT_SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.hints.map((hint) => ( +
+ + {hint.key} + + {hint.label} +
+ ))} +
+
+ ))} +
+
+ )}
); diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts index f4b18e4f7..d7adf8098 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts @@ -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) => { diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 727f17a58..ff2d3a026 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 2fce59381..4b96c2634 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -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((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((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((set) => ({ timelineReady: false, elements: [], selectedElementId: null, + inPoint: null, + outPoint: null, }), }));