diff --git a/packages/studio/src/player/components/AudioWaveform.tsx b/packages/studio/src/player/components/AudioWaveform.tsx index 227133e0ab..84a3acdbba 100644 --- a/packages/studio/src/player/components/AudioWaveform.tsx +++ b/packages/studio/src/player/components/AudioWaveform.tsx @@ -41,31 +41,28 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] { return peaks.map((p) => p / maxPeak); } -/** Deterministic fake waveform as fallback (matches demo app). */ -function fakePeaks(url: string, count: number): number[] { - let seed = 0; - for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0; - seed = Math.abs(seed) || 42; - const rand = () => { - seed = (seed * 16807) % 2147483647; - return (seed & 0x7fffffff) / 2147483647; - }; - const peaks: number[] = []; - for (let i = 0; i < count; i++) { - const t = i / count; - const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1); - peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand())))); - } - return peaks; -} - // Module-level cache so decoded audio persists across re-renders and re-mounts const peaksCache = new Map(); -const decodeInFlight = new Map>(); +const decodeInFlight = new Map>(); +// URLs whose fetch/decode recently failed — render the degraded state instead +// of refetch-looping (and never fabricate plausible-looking peaks the user +// might trim or beat-align against). Failures expire so a transient error +// (server restarting, file mid-import) doesn't pin "unavailable" all session. +const decodeFailed = new Map(); +const DECODE_RETRY_MS = 30_000; +function hasRecentDecodeFailure(key: string): boolean { + const failedAt = decodeFailed.get(key); + if (failedAt === undefined) return false; + if (Date.now() - failedAt > DECODE_RETRY_MS) { + decodeFailed.delete(key); + return false; + } + return true; +} /** * Audio waveform rendered from real PCM data via Web Audio API. - * Falls back to a deterministic fake pattern if decoding fails. + * Shows an explicit "waveform unavailable" degraded state if decoding fails. * Bars grow from bottom to top, rendered as CSS divs for zoom resilience. */ export const AudioWaveform = memo(function AudioWaveform({ @@ -81,9 +78,17 @@ export const AudioWaveform = memo(function AudioWaveform({ const roRef = useRef(null); const cacheKey = waveformUrl ?? audioUrl; const [peaks, setPeaks] = useState(peaksCache.get(cacheKey) ?? null); + const [decodeError, setDecodeError] = useState(() => hasRecentDecodeFailure(cacheKey)); + // Re-sync when the clip's audio source is swapped on the same mounted + // component — a stale error (or stale peaks) must not block the new URL. useEffect(() => { - if (peaks || !cacheKey) return; + setPeaks(peaksCache.get(cacheKey) ?? null); + setDecodeError(hasRecentDecodeFailure(cacheKey)); + }, [cacheKey]); + + useEffect(() => { + if (peaks || decodeError || !cacheKey) return; let cancelled = false; @@ -105,10 +110,13 @@ export const AudioWaveform = memo(function AudioWaveform({ }) .then((decoded) => extractPeaks(decoded.getChannelData(0), 4000)) ) - .catch(() => fakePeaks(cacheKey, 4000)) - .then((p) => { + .then((p: number[]) => { peaksCache.set(cacheKey, p); - return p; + return p as number[] | null; + }) + .catch(() => { + decodeFailed.set(cacheKey, Date.now()); + return null; }) .finally(() => decodeInFlight.delete(cacheKey)); @@ -116,12 +124,14 @@ export const AudioWaveform = memo(function AudioWaveform({ } promise.then((p) => { - if (!cancelled) setPeaks(p); + if (cancelled) return; + if (p) setPeaks(p); + else setDecodeError(true); }); return () => { cancelled = true; }; - }, [audioUrl, waveformUrl, cacheKey, peaks]); + }, [audioUrl, waveformUrl, cacheKey, peaks, decodeError]); // Draw bars into the container using innerHTML (fast, zoom-resilient) const draw = useCallback(() => { @@ -185,9 +195,9 @@ export const AudioWaveform = memo(function AudioWaveform({
{/* Shimmer while decoding */} - {!peaks && ( + {!peaks && !decodeError && (
)} + {/* Degraded state — decode failed; render an explicit flat placeholder + instead of fabricated peaks the user might edit against. */} + {decodeError && ( +
+
+ + waveform unavailable + +
+ )}
{ - e.stopPropagation(); - deleteBeatAtCompositionTime(t); - usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat - }} >
@@ -51,7 +55,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({ <>
{/* Action */} + {copyError && ( +

+ Copy failed — check clipboard permissions and try again. +

+ )}
)} - {/* Delete */} + {onCopyProperties && ( + + )} + + {onChangeEase && ( + <> + + {showEaseList && ( +
+ {STUDIO_GSAP_EASE_OPTIONS.map((ease) => { + const isCurrent = ease === state.currentEase; + return ( + + ); + })} +
+ )} + + )} + +
+ +
+
, document.body, diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index d23dbec269..012ba72e4f 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -121,12 +121,16 @@ export const Player = forwardRef( const loadCountRef = useRef(0); const assetPollRef = useRef | null>(null); const assetFadeRef = useRef | null>(null); + const playerElRef = useRef(null); + const srcRef = useRef(""); const [assetsLoading, setAssetsLoading] = useState(false); const [assetOverlayVisible, setAssetOverlayVisible] = useState(false); const [assetOverlayFading, setAssetOverlayFading] = useState(false); + const [assetWaitLong, setAssetWaitLong] = useState(false); const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false); const [compositionLoading, setCompositionLoading] = useState(true); const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true); + const [loadError, setLoadError] = useState(false); // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -155,6 +159,8 @@ export const Player = forwardRef( // Create the web component imperatively to avoid JSX custom-element typing. const player = document.createElement("hyperframes-player") as HyperframesPlayerElement; const src = directUrl || `/api/projects/${projectId}/preview`; + playerElRef.current = player; + srcRef.current = src; player.setAttribute("shader-capture-scale", "1"); player.setAttribute("shader-loading", "player"); player.setAttribute("src", src); @@ -201,9 +207,11 @@ export const Player = forwardRef( const handleReady = () => { setCompositionLoading(false); + setLoadError(false); }; const handleError = () => { setCompositionLoading(false); + setLoadError(true); }; player.addEventListener("ready", handleReady); player.addEventListener("error", handleError); @@ -213,6 +221,7 @@ export const Player = forwardRef( loadCountRef.current++; setShaderTransitionLoading(false); setCompositionLoading(true); + setLoadError(false); // Reveal animation on reload (hot-reload, composition switch) if (loadCountRef.current > 1) { container.classList.remove("preview-revealing"); @@ -249,6 +258,11 @@ export const Player = forwardRef( attempts += 1; lastUnloaded = hasUnloadedAssets(iframe, lastUnloaded); if (!lastUnloaded || attempts > 100) { + if (lastUnloaded && attempts > 100) { + console.debug( + "[studio] asset readiness poll hit the 10s cap — continuing with unloaded assets", + ); + } if (assetPollRef.current) clearInterval(assetPollRef.current); assetPollRef.current = null; setAssetsLoading(false); @@ -268,6 +282,7 @@ export const Player = forwardRef( player.removeEventListener("error", handleError); if (assetPollRef.current) clearInterval(assetPollRef.current); assetPollRef.current = null; + if (playerElRef.current === player) playerElRef.current = null; container.removeChild(player); // Clear the forwarded ref only if it still points to THIS iframe. // During crossfade refreshes the retiring Player unmounts after the @@ -293,6 +308,17 @@ export const Player = forwardRef( }; }); + // Surface a "Continue anyway" escape hatch once the asset wait drags on. + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!assetsLoading) { + setAssetWaitLong(false); + return; + } + const timer = setTimeout(() => setAssetWaitLong(true), 3000); + return () => clearTimeout(timer); + }, [assetsLoading]); + useEffect(() => { if (assetFadeRef.current) { clearTimeout(assetFadeRef.current); @@ -320,12 +346,28 @@ export const Player = forwardRef( }; }, [assetsLoading]); + const handleRetryLoad = () => { + const player = playerElRef.current; + if (!player || !srcRef.current) return; + setLoadError(false); + setCompositionLoading(true); + player.setAttribute("src", srcRef.current); + }; + + const handleContinueAnyway = () => { + if (assetPollRef.current) { + clearInterval(assetPollRef.current); + assetPollRef.current = null; + } + setAssetsLoading(false); + }; + const showCompositionOverlay = !suppressLoadingOverlay && !compositionOverlayDeferred && shouldShowCompositionLoadingOverlay(compositionLoading); const showAssetOverlay = - assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay; + assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay && !loadError; useEffect(() => { onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay); @@ -362,17 +404,50 @@ export const Player = forwardRef( style={{ opacity: assetOverlayFading ? 0 : 1, pointerEvents: assetOverlayFading ? "none" : "auto", - transition: "opacity 240ms ease-out", + transition: "opacity 180ms ease-in", }} onDragStart={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()} - onPointerDown={(event) => event.preventDefault()} > - +
+ + {assetWaitLong && ( + + )} +
+
+ )} + {loadError && !showCompositionOverlay && ( +
+
+ + Couldn't load the composition preview + + + The preview failed to load. Check that the composition file is valid, then retry. + + +
)}
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 5df14ed537..ffe308cded 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -308,7 +308,6 @@ const SeekBar = memo(function SeekBar({ aria-disabled={disabled || undefined} aria-valuemin={0} aria-valuemax={Math.round(duration)} - aria-valuenow={0} className={`min-w-[96px] flex-1 h-6 flex items-center group outline-none focus-visible:ring-1 focus-visible:ring-white/30 focus-visible:rounded ${ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" }`} @@ -328,7 +327,7 @@ const SeekBar = memo(function SeekBar({ />
(null); + const closePanel = useCallback(() => setShowShortcuts(false), []); + // Ref covers trigger + panel; closes on outside click AND Escape. + const shortcutsPanelRef = useContextMenuDismiss(closePanel); + const panelBodyRef = useRef(null); + const triggerRef = useRef(null); + // Move focus into the panel on open so keyboard users can scroll/operate it; + // return focus to the trigger on close. + // eslint-disable-next-line no-restricted-syntax 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); - }; + if (showShortcuts) { + const trigger = triggerRef.current; + panelBodyRef.current?.focus(); + return () => { + trigger?.focus(); + }; + } }, [showShortcuts]); const commitJumpFrame = useCallback(() => { @@ -144,6 +150,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
- ))} + {SPEED_OPTIONS.map((rate) => { + const isCurrent = rate === playbackRate; + return ( + + ); + })}
)}
diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index a84b4c2b6e..6e4a91d236 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -86,7 +86,7 @@ describe("Timeline provider boundary", () => { root.render(React.createElement(Timeline, { onSeek })); }); - const diamond = host.querySelector('button[title="50%"]'); + const diamond = host.querySelector('button[title="Keyframe at 50%"]'); expect(diamond).not.toBeNull(); act(() => { diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index a90fc99393..d3b014f255 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -6,6 +6,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; import { useMountEffect } from "../../hooks/useMountEffect"; import { EditPopover } from "./EditModal"; +import { copyTextToClipboard } from "../../utils/clipboard"; import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; @@ -531,7 +532,9 @@ export const Timeline = memo(function Timeline({ Shift - + drag/click to edit range + {activeTool === "razor" + ? "+ click to split all tracks" + : "+ drag/click to edit range"}
@@ -565,9 +568,8 @@ export const Timeline = memo(function Timeline({ onCopyProperties={(elId, pct) => { const kfData = keyframeCache.get(elId); const kf = kfData?.keyframes.find((k) => k.percentage === pct); - if (kf) { - void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2)); - } + if (!kf) return false; + return copyTextToClipboard(JSON.stringify(kf.properties, null, 2)); }} /> )} diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index d1ba71f15b..1a3546ba79 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -420,6 +420,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({ setSelectedElementId(nextElement ? elementKey : null); onSelectElement?.(nextElement); }} + onKeyActivate={() => { + const nextElement = isSelected ? null : el; + setSelectedElementId(nextElement ? elementKey : null); + onSelectElement?.(nextElement); + }} onDoubleClick={(e) => { e.stopPropagation(); if (suppressClickRef.current) return; diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 914b2da2ef..43992a5260 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -24,6 +24,8 @@ interface TimelineClipProps { onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void; + /** Keyboard activation (Enter/Space on the focused clip) — toggles selection. */ + onKeyActivate?: () => void; children?: ReactNode; } @@ -46,6 +48,7 @@ export const TimelineClip = memo(function TimelineClip({ onClick, onDoubleClick, onContextMenu, + onKeyActivate, children, }: TimelineClipProps) { const leftPx = el.start * pps; @@ -70,11 +73,15 @@ export const TimelineClip = memo(function TimelineClip({ return (
{ + if (e.key !== "Enter" && e.key !== " ") return; + // Ignore keystrokes bubbling from focusable children (keyframe diamonds). + if (e.target !== e.currentTarget) return; + e.preventDefault(); + e.stopPropagation(); + onKeyActivate?.(); + }} > {/* Left accent stripe — wider + brighter for expanded sub-comp children */}
{ it("treats primary pointerup without drag as a keyframe click", () => { const { host, root, onClickKeyframe } = renderDiamonds(); - const diamond = host.querySelector('button[title="50%"]'); + const diamond = host.querySelector('button[title="Keyframe at 50%"]'); expect(diamond).not.toBeNull(); act(() => { @@ -60,7 +60,7 @@ describe("TimelineClipDiamonds", () => { it("does not treat secondary pointerup as a keyframe click", () => { const { host, root, onClickKeyframe } = renderDiamonds(); - const diamond = host.querySelector('button[title="50%"]'); + const diamond = host.querySelector('button[title="Keyframe at 50%"]'); expect(diamond).not.toBeNull(); act(() => { @@ -104,7 +104,7 @@ describe("TimelineClipDiamonds", () => { />, ); }); - const diamond = host.querySelector('button[title="50%"]'); + const diamond = host.querySelector('button[title="Keyframe at 50%"]'); expect(diamond).not.toBeNull(); act(() => { @@ -154,7 +154,7 @@ describe("TimelineClipDiamonds", () => { />, ); }); - const diamond = host.querySelector('button[title="50%"]'); + const diamond = host.querySelector('button[title="Keyframe at 50%"]'); expect(diamond).not.toBeNull(); act(() => { @@ -202,7 +202,7 @@ describe("TimelineClipDiamonds", () => { />, ); }); - const diamond = host.querySelector('button[title="50%"]'); + const diamond = host.querySelector('button[title="Keyframe at 50%"]'); expect(diamond).not.toBeNull(); act(() => { diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 129104dfb8..f93b14201e 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -268,12 +268,22 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} + onClick={(e) => { + // Keyboard activation only (Enter/Space synthesize a click with + // detail === 0); pointer presses are handled in onPointerUp and + // their synthesized click is suppressed via suppressNextClick. + if (e.detail !== 0) return; + e.stopPropagation(); + if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); + else onClickKeyframe?.(kf.percentage); + }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenuKeyframe?.(e, elementId, kf.percentage); }} - title={`${kf.percentage}%`} + title={`Keyframe at ${Math.round(kf.percentage)}%`} + aria-label={`Keyframe at ${Math.round(kf.percentage)}%`} > {isKfSelected && ( diff --git a/packages/studio/src/player/components/VideoThumbnail.tsx b/packages/studio/src/player/components/VideoThumbnail.tsx index 83a134e4e2..7acd65f6f3 100644 --- a/packages/studio/src/player/components/VideoThumbnail.tsx +++ b/packages/studio/src/player/components/VideoThumbnail.tsx @@ -25,6 +25,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({ const [containerWidth, setContainerWidth] = useState(0); const [visible, setVisible] = useState(false); const [frames, setFrames] = useState([]); + const [failed, setFailed] = useState(false); const [aspect, setAspect] = useState(16 / 9); const ioRef = useRef(null); const roRef = useRef(null); @@ -125,7 +126,9 @@ export const VideoThumbnail = memo(function VideoThumbnail({ }); video.addEventListener("error", () => { - /* keep whatever frames we have */ + // Keep whatever frames we have; with none, mark failed so the shimmer + // resolves to a static placeholder instead of pulsing forever. + if (!cancelled) setFailed(true); }); video.src = videoSrc; @@ -135,6 +138,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({ cancelled = true; extractingRef.current = false; setFrames([]); + setFailed(false); video.src = ""; video.load(); }; @@ -167,9 +171,9 @@ export const VideoThumbnail = memo(function VideoThumbnail({
)} - {visible && frames.length === 0 && ( + {visible && frames.length === 0 && !failed && (
)} + {visible && frames.length === 0 && failed && ( +
+ no preview +
+ )} +
): void { + useEffect(() => { + const menu = menuRef.current; + if (!menu) return; + const previouslyFocused = document.activeElement; + + const items = () => + Array.from(menu.querySelectorAll('[role="menuitem"]')).filter( + (el) => !el.disabled, + ); + items()[0]?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") { + return; + } + const list = items(); + if (list.length === 0) return; + e.preventDefault(); + const idx = list.findIndex((el) => el === document.activeElement); + let next: number; + if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % list.length; + else if (e.key === "ArrowUp") + next = idx < 0 ? list.length - 1 : (idx - 1 + list.length) % list.length; + else if (e.key === "Home") next = 0; + else next = list.length - 1; + list[next]?.focus(); + }; + menu.addEventListener("keydown", onKeyDown); + + return () => { + menu.removeEventListener("keydown", onKeyDown); + if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) { + previouslyFocused.focus(); + } + }; + }, [menuRef]); +}