mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
## Summary Base of the studio UX-review stack (148 findings audited across the studio; 13 critical). This PR hardens the shared `components/ui` primitives that every later PR in the stack builds on. ## Changes - **Button / IconButton**: visible `focus-visible` outline (studio accent); `disabled:pointer-events-none` removed (replaced with `disabled:cursor-not-allowed`, hover/active gated behind `enabled:`) so disabled buttons can host explain-why tooltips. - **Tooltip**: keyboard support (`onFocus`/`onBlur` triggers), `role="tooltip"`, Escape-to-hide, viewport flip (top↔bottom) + horizontal clamping. API unchanged — all ~28 call sites unaffected. - **HyperframesLoader**: `role="status"` on the loader; determinate track is a real `role="progressbar"` with `aria-valuenow/min/max` (was `aria-hidden`). - **VideoFrameThumbnail**: error event resolves to a static fallback-label tile instead of an infinite shimmer; `motion-reduce` guard. - **NEW `useDialogBehavior`**: shared modal contract — document-level Escape, Tab focus trap, focus-first-on-open, focus-restore-on-close, `canClose()` veto for dirty-draft guards. Adopted by every modal later in the stack. - **NEW `SearchInput`**: shared search primitive with required `aria-label`, panel-input token style (kills the two-divergent-search-styles inconsistency in the sidebar). - **studio.css**: `hf-toast-in/out` + `hf-backdrop-in` keyframes with `prefers-reduced-motion` guards (the previous `animate-in fade-in` classes were dead — no tailwindcss-animate plugin exists). ## Verification - oxlint 0 errors, oxfmt clean, `tsc --noEmit` clean at stack top - Full studio suite at stack top: 1189 tests pass ## Stack PR 1/7 of the studio UX-review fixes. Merges bottom-up; the stack top is fully green (tsc + 1189 tests). Some shared-file edits span PRs, so intermediate branches may not typecheck in isolation. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
74 lines
2.0 KiB
TypeScript
74 lines
2.0 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
|
|
/**
|
|
* Extracts a representative JPEG frame from a video URL using a hidden
|
|
* video + canvas. Seeks to ~10% of duration to avoid black opening frames.
|
|
* Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
|
|
*/
|
|
export function VideoFrameThumbnail({
|
|
src,
|
|
fallbackLabel,
|
|
}: {
|
|
src: string;
|
|
/** Shown instead of an endless shimmer when the video can't be decoded. */
|
|
fallbackLabel?: string;
|
|
}) {
|
|
const [frame, setFrame] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setFailed(false);
|
|
const video = document.createElement("video");
|
|
video.crossOrigin = "anonymous";
|
|
video.muted = true;
|
|
video.preload = "metadata";
|
|
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
const cleanup = () => {
|
|
video.src = "";
|
|
video.load();
|
|
};
|
|
|
|
video.addEventListener("loadedmetadata", () => {
|
|
video.currentTime = Math.min(2, video.duration * 0.1 || 2);
|
|
});
|
|
|
|
video.addEventListener("seeked", () => {
|
|
if (!ctx) return;
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0);
|
|
setFrame(canvas.toDataURL("image/jpeg", 0.7));
|
|
cleanup();
|
|
});
|
|
|
|
video.addEventListener("error", () => {
|
|
// Resolve the loading state — a permanent shimmer reads as "still loading".
|
|
setFailed(true);
|
|
cleanup();
|
|
});
|
|
video.src = src;
|
|
video.load();
|
|
|
|
return cleanup;
|
|
}, [src]);
|
|
|
|
if (failed && !frame) {
|
|
return (
|
|
<div className="w-full h-full bg-neutral-800 flex items-center justify-center">
|
|
<span className="text-[9px] font-medium text-neutral-600">{fallbackLabel ?? "VIDEO"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!frame) {
|
|
return (
|
|
<div className="w-full h-full bg-neutral-800 animate-pulse motion-reduce:animate-none" />
|
|
);
|
|
}
|
|
|
|
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
|
|
}
|