mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): player UX — honest waveform, keyframe menu actions, beat-delete gesture (#1967)
Player and timeline fixes from the studio UX review, reconciled against six weeks of main. Honest media states: - AudioWaveform no longer falls back to synthesised sine-wave peaks when a decode fails. The failure propagates and the clip renders a dashed flat line + "waveform unavailable" instead of a plausible waveform an author would trim and beat-align against. Main's thumbnail scheduler already caches the failure with a TTL, so this neither refetch-loops nor pins the degraded state past a transient error. - VideoThumbnail renders a static "no preview" placeholder on a failed decode rather than resolving to an empty box. Keyframe context menu, restored: - "Edit Ease…" (showing the current ease) and "Copy Properties" (async, "Copied!"/"Copy failed") were plumbed but never rendered. Edit Ease routes to the same focused-ease-segment path a segment click takes, so the menu advertises the editor that exists instead of growing a second one; it is offered only for a keyframe that names a tween to focus. Copy Properties matches the keyframe cache on clip-% with the same tolerance main's move-to-playhead uses. Every row is a role="menuitem" with arrow-key navigation and focus handling via the new useMenuKeyboardNav helper, and a separator now isolates "Delete All Keyframes" from the single delete. Error prevention: - Beat dots: hit target 12→24px (WCAG 2.5.8), and delete moves off double-click to ⌥-click — a stuttered drag reads as a double-click and would destroy the beat. ⌥ starts no drag, so a slipped ⌥-drag abandons instead of deleting. - ShortcutsPanel moves focus into the panel on open and returns it to the trigger on close; SpeedMenu's trigger is labelled and reports its popup. Superseded by main, deliberately dropped: the seek-slider keyboard and aria-valuenow fixes (the transport no longer owns a seek bar), the Player load-error inline retry (main's reports the actual message and retries with a cache-busting src), TimelineClip keyboard selection (main renders a native button, and this PR's onKeyDown would have preventDefault'ed the synthesized click), the keyframe-diamond keyboard guard and label (both already on main, with a richer label), and the waveform's own cache/failure maps (main's scheduler owns that). TimelineOverlays.tsx is a main-side file edited to thread the two restored menu actions; BeatStrip.test.tsx tracks the new gesture and hit target. Restacked onto main now that PRs 1962-1966 have squash-merged, so this carries only its own changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
44791c3f7d
commit
a01a5d7b3c
@@ -35,37 +35,19 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
||||
return peaks.map((peak) => peak / maxPeak);
|
||||
}
|
||||
|
||||
function fakePeaks(url: string, count: number): number[] {
|
||||
let seed = 0;
|
||||
for (let index = 0; index < url.length; index++) {
|
||||
seed = ((seed << 5) - seed + url.charCodeAt(index)) | 0;
|
||||
}
|
||||
seed = Math.abs(seed) || 42;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed & 0x7fffffff) / 2147483647;
|
||||
};
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const time = index / count;
|
||||
const envelope =
|
||||
0.3 + 0.3 * Math.sin(time * Math.PI * 3.2) + 0.2 * Math.sin(time * Math.PI * 7.1);
|
||||
return Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * random())));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWaveform(
|
||||
audioUrl: string,
|
||||
waveformUrl: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
return waveformUrl
|
||||
? await fetchWaveformPeaks(waveformUrl, signal)
|
||||
: await decodeWaveformPeaks(audioUrl, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
return fakePeaks(waveformUrl ?? audioUrl, 4000);
|
||||
}
|
||||
// Failures propagate. Synthesised peaks are worse than an honest gap: an
|
||||
// author trims and beat-aligns against this waveform, and a plausible
|
||||
// fabrication is indistinguishable from the real thing while being wrong.
|
||||
// The scheduler caches the failure (metadataFailureTtlMs) so the degraded
|
||||
// state neither refetch-loops nor pins itself past a transient error.
|
||||
return waveformUrl
|
||||
? await fetchWaveformPeaks(waveformUrl, signal)
|
||||
: await decodeWaveformPeaks(audioUrl, signal);
|
||||
}
|
||||
|
||||
async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
|
||||
@@ -193,6 +175,27 @@ export const AudioWaveform = memo(function AudioWaveform({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Degraded state — the decode failed; say so rather than paint a
|
||||
waveform the author could edit against. */}
|
||||
{snapshot.status === "error" && (
|
||||
<div
|
||||
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
||||
style={{ top: 16, bottom: 0 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
style={{
|
||||
bottom: "20%",
|
||||
height: 2,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
|
||||
}}
|
||||
/>
|
||||
<span className="relative rounded bg-black/50 px-1 text-[8px] text-neutral-500">
|
||||
waveform unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{label && (
|
||||
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
||||
<span
|
||||
|
||||
@@ -74,9 +74,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
|
||||
}
|
||||
|
||||
function firstBeat(): HTMLDivElement {
|
||||
const beat = document.querySelector<HTMLDivElement>(
|
||||
'[title="Drag to move · double-click to delete"]',
|
||||
);
|
||||
const beat = document.querySelector<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]');
|
||||
if (!beat) throw new Error("Expected a beat handle");
|
||||
return beat;
|
||||
}
|
||||
@@ -256,9 +254,7 @@ describe("BeatStrip gesture ownership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
|
||||
).toHaveLength(2);
|
||||
expect(document.querySelectorAll('[title="Drag to move · ⌥-click to delete"]')).toHaveLength(2);
|
||||
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
@@ -297,10 +293,10 @@ describe("BeatStrip gesture ownership", () => {
|
||||
});
|
||||
|
||||
const lefts = Array.from(
|
||||
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · double-click to delete"]'),
|
||||
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]'),
|
||||
(beat) => beat.style.left,
|
||||
);
|
||||
expect(lefts).toContain("134px");
|
||||
expect(lefts).toContain("128px");
|
||||
|
||||
releaseBeatDrag(140);
|
||||
expectCommittedBeatAt(1.4);
|
||||
@@ -310,7 +306,7 @@ describe("BeatStrip gesture ownership", () => {
|
||||
mountBeatStrip();
|
||||
startBeatDrag();
|
||||
const beats = document.querySelectorAll<HTMLDivElement>(
|
||||
'[title="Drag to move · double-click to delete"]',
|
||||
'[title="Drag to move · ⌥-click to delete"]',
|
||||
);
|
||||
act(() => {
|
||||
beats[1]?.dispatchEvent(
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
|
||||
export const BEAT_BAND_H = 14; // dark band height at top of track
|
||||
const BEAT_HIT_W = 12; // grab width per beat (px)
|
||||
const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
|
||||
|
||||
interface BeatDragActor {
|
||||
readonly pointerId: number;
|
||||
@@ -355,8 +355,9 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
||||
|
||||
/**
|
||||
* Green beat dots on the music track's row. Drag a dot to move its beat,
|
||||
* double-click to delete; both scrub the audio. Dot size/brightness scale with
|
||||
* beat loudness (gamma-curved for contrast).
|
||||
* ⌥-click to delete (kept off double-click so a stuttered drag can't destroy
|
||||
* a beat); both scrub the audio. Dot size/brightness scale with beat loudness
|
||||
* (gamma-curved for contrast). Deletes remain undoable via ⌘Z.
|
||||
*/
|
||||
export const BeatStrip = memo(function BeatStrip({
|
||||
beatTimes,
|
||||
@@ -412,7 +413,7 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
<div
|
||||
key={`${t}-${i}`}
|
||||
className="absolute select-none"
|
||||
title="Drag to move · double-click to delete"
|
||||
title="Drag to move · ⌥-click to delete"
|
||||
draggable={false}
|
||||
style={{
|
||||
left: x - BEAT_HIT_W / 2,
|
||||
@@ -428,9 +429,15 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
// selection (which otherwise "selects" the whole panel mid-drag).
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// ⌥ starts no drag: the delete lands on release below, so a
|
||||
// slipped ⌥-drag abandons instead of destroying the beat.
|
||||
if (e.altKey) return;
|
||||
beginBeatDrag(e, t, pps);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
onClick={(e) => {
|
||||
if (!e.altKey) return;
|
||||
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
|
||||
// attempt reads as a double-click and would destroy the beat.
|
||||
e.stopPropagation();
|
||||
deleteBeatAtCompositionTime(t);
|
||||
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { canSplitElement } from "../../utils/timelineElementSplit";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
||||
|
||||
interface ClipContextMenuProps {
|
||||
x: number;
|
||||
@@ -24,6 +25,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
onDelete,
|
||||
}: ClipContextMenuProps) {
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
useMenuKeyboardNav(menuRef);
|
||||
|
||||
const menuWidth = 200;
|
||||
const menuHeight = 80;
|
||||
@@ -44,6 +46,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label="Clip actions"
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
@@ -51,7 +55,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
|
||||
role="menuitem"
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
|
||||
canSplit
|
||||
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
||||
: "text-neutral-600 cursor-not-allowed"
|
||||
@@ -73,7 +78,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(element);
|
||||
onClose();
|
||||
|
||||
@@ -13,16 +13,43 @@ interface EditPopoverProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function draftKey(start: number, end: number): string {
|
||||
return `hf-edit-draft:${start.toFixed(2)}:${end.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function readDraft(key: string): string {
|
||||
try {
|
||||
return sessionStorage.getItem(key) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(key: string, value: string): void {
|
||||
try {
|
||||
if (value) sessionStorage.setItem(key, value);
|
||||
else sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
/* storage unavailable — draft persistence degrades gracefully */
|
||||
}
|
||||
}
|
||||
|
||||
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
||||
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const start = Math.min(rangeStart, rangeEnd);
|
||||
const end = Math.max(rangeStart, rangeEnd);
|
||||
// Persist the typed prompt per range so Escape/outside-click doesn't destroy it.
|
||||
const storageKey = draftKey(start, end);
|
||||
const [prompt, setPromptState] = useState(() => readDraft(storageKey));
|
||||
const setPrompt = (value: string) => {
|
||||
setPromptState(value);
|
||||
writeDraft(storageKey, value);
|
||||
};
|
||||
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
||||
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const elementsInRange = useMemo(() => {
|
||||
return elements.filter((el) => {
|
||||
@@ -64,19 +91,28 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const copied = await copyTextToClipboard(buildClipboardText());
|
||||
if (!copied) return;
|
||||
if (!copied) {
|
||||
setCopyError(true);
|
||||
return;
|
||||
}
|
||||
setCopyError(false);
|
||||
writeDraft(storageKey, "");
|
||||
setCopiedAgentPrompt(true);
|
||||
setTimeout(() => {
|
||||
setCopiedAgentPrompt(false);
|
||||
onClose();
|
||||
}, 800);
|
||||
}, [buildClipboardText, onClose]);
|
||||
}, [buildClipboardText, onClose, storageKey]);
|
||||
|
||||
const handleCopyPrompt = useCallback(async () => {
|
||||
const promptText = buildPromptCopyText(prompt);
|
||||
if (!promptText) return;
|
||||
const copied = await copyTextToClipboard(promptText);
|
||||
if (!copied) return;
|
||||
if (!copied) {
|
||||
setCopyError(true);
|
||||
return;
|
||||
}
|
||||
setCopyError(false);
|
||||
setCopiedPromptOnly(true);
|
||||
setTimeout(() => {
|
||||
setCopiedPromptOnly(false);
|
||||
@@ -137,6 +173,11 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{copyError && (
|
||||
<p className="px-3 pb-2 text-[10px] text-red-400" role="alert">
|
||||
Copy failed — check clipboard permissions and try again.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
|
||||
<button
|
||||
onClick={handleCopyPrompt}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
|
||||
@@ -25,45 +27,83 @@ interface KeyframeDiamondContextMenuProps {
|
||||
* worse than no entry. */
|
||||
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onDeleteAll: (element: TimelineElement, animationId?: string) => void;
|
||||
/** Focus this keyframe's ease segment in the inspector. Omitted when the
|
||||
* keyframe carries no tween identity to focus. */
|
||||
onEditEase?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
/** Copy the keyframe's properties to the clipboard; resolves false on failure. */
|
||||
onCopyProperties?: (
|
||||
elementId: string,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
) => Promise<boolean> | boolean | void;
|
||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
||||
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
}
|
||||
|
||||
const ITEM_CLS =
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
|
||||
const DESTRUCTIVE_ITEM_CLS =
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
|
||||
|
||||
export function KeyframeDiamondContextMenu({
|
||||
state,
|
||||
onClose,
|
||||
onDelete,
|
||||
onDeleteAll,
|
||||
onEditEase,
|
||||
onCopyProperties,
|
||||
onMoveToPlayhead,
|
||||
}: KeyframeDiamondContextMenuProps) {
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
// The clicked diamond's identity, built once: the menu's two mutating entries
|
||||
// both act on it, and they must not disagree about which keyframe was clicked.
|
||||
// The clicked diamond's identity, built once: the menu's mutating entries
|
||||
// all act on it, and they must not disagree about which keyframe was clicked.
|
||||
const keyframe: TimelineKeyframeTarget = {
|
||||
percentage: state.percentage,
|
||||
tweenPercentage: state.tweenPercentage,
|
||||
propertyGroup: state.propertyGroup,
|
||||
animationId: state.animationId,
|
||||
};
|
||||
useMenuKeyboardNav(menuRef);
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
const handleCopyProperties = async () => {
|
||||
if (!onCopyProperties) return;
|
||||
const result = await onCopyProperties(state.elementId, keyframe);
|
||||
if (result === false) {
|
||||
setCopyStatus("failed");
|
||||
setTimeout(() => setCopyStatus("idle"), 1500);
|
||||
return;
|
||||
}
|
||||
setCopyStatus("copied");
|
||||
setTimeout(onClose, 700);
|
||||
};
|
||||
|
||||
const menuWidth = 200;
|
||||
// Measured off the rendered rows, so the flip-up test below stays right as
|
||||
// optional entries drop out.
|
||||
const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
|
||||
// optional entries drop out. The separator counts as roughly a third of a row.
|
||||
const rows =
|
||||
1 +
|
||||
(onMoveToPlayhead ? 1 : 0) +
|
||||
(onEditEase ? 1 : 0) +
|
||||
(onCopyProperties ? 1 : 0) +
|
||||
(onDelete ? 1 : 0);
|
||||
const menuHeight = 10 + rows * 30 + 9;
|
||||
const overflowY = state.y + menuHeight - window.innerHeight;
|
||||
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
||||
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
||||
const adjustedY = Math.max(8, overflowY > 0 ? state.y - overflowY - 8 : state.y);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
role="menu"
|
||||
aria-label="Keyframe actions"
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
|
||||
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
|
||||
>
|
||||
{onMoveToPlayhead && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className={ITEM_CLS}
|
||||
onClick={() => {
|
||||
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
||||
// and returns the tween-% for the mutation. Passing tween-% here would
|
||||
@@ -76,11 +116,44 @@ export function KeyframeDiamondContextMenu({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onEditEase && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${ITEM_CLS} justify-between`}
|
||||
onClick={() => {
|
||||
onEditEase(state.elementId, keyframe);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span>Edit Ease…</span>
|
||||
<span className="text-[10px] text-neutral-500">{state.currentEase ?? "default"}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onCopyProperties && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM_CLS}
|
||||
onClick={() => {
|
||||
void handleCopyProperties();
|
||||
}}
|
||||
>
|
||||
{copyStatus === "copied"
|
||||
? "Copied!"
|
||||
: copyStatus === "failed"
|
||||
? "Copy failed — check permissions"
|
||||
: "Copy Properties"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className={DESTRUCTIVE_ITEM_CLS}
|
||||
onClick={() => {
|
||||
onDelete(state.elementId, keyframe);
|
||||
onClose();
|
||||
@@ -90,9 +163,16 @@ export function KeyframeDiamondContextMenu({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Deleting every keyframe sat adjacent to the single delete and styled
|
||||
identically. Separate and mark it so the two cannot be misread. */}
|
||||
<div className="my-1 border-t border-neutral-700/60" role="separator" />
|
||||
|
||||
<div className="my-1 border-t border-neutral-700/60" role="separator" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDeleteAll(state.element, state.animationId);
|
||||
onClose();
|
||||
|
||||
@@ -135,6 +135,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
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);
|
||||
@@ -236,6 +237,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
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);
|
||||
@@ -327,6 +333,17 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
};
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -354,12 +371,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
};
|
||||
}, [assetsLoading]);
|
||||
|
||||
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 && !previewError;
|
||||
|
||||
useEffect(() => {
|
||||
onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay);
|
||||
@@ -396,17 +421,27 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
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()}
|
||||
>
|
||||
<HyperframesLoader
|
||||
title="Preparing preview assets"
|
||||
detail="Waiting for media and motion assets before playback starts."
|
||||
size={56}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<HyperframesLoader
|
||||
title="Preparing preview assets"
|
||||
detail="Waiting for media and motion assets before playback starts."
|
||||
size={56}
|
||||
/>
|
||||
{assetWaitLong && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinueAnyway}
|
||||
className="px-3 py-1.5 text-[11px] rounded-md border border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Continue anyway
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewError && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useId, memo } from "react";
|
||||
import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
|
||||
import { formatTime, frameToSeconds } from "../lib/time";
|
||||
import { Tooltip } from "../../components/ui";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
@@ -19,7 +19,7 @@ const SHORTCUT_SECTIONS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Keyframes",
|
||||
title: "Keyframes (when an element is selected)",
|
||||
hints: [
|
||||
{ key: "K", label: "Add keyframe at playhead" },
|
||||
{ key: "Del", label: "Delete selected keyframe" },
|
||||
@@ -37,9 +37,10 @@ const SHORTCUT_SECTIONS = [
|
||||
{ key: "⌘V", label: "Paste element" },
|
||||
{ key: "⌘X", label: "Cut element" },
|
||||
{ key: "S", label: "Split clip at playhead" },
|
||||
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
||||
{ key: "⌘G", label: "Group elements" },
|
||||
{ key: "⌘⇧G", label: "Ungroup" },
|
||||
{ key: "Del", label: "Delete selected element" },
|
||||
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -112,6 +113,20 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
||||
const shortcutsPanelId = useId();
|
||||
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
|
||||
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
|
||||
const panelBodyRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Move focus into the panel on open so keyboard users can scroll and read
|
||||
// it; hand focus back to the trigger on close.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!showShortcuts) return;
|
||||
const trigger = triggerRef.current;
|
||||
panelBodyRef.current?.focus();
|
||||
return () => {
|
||||
trigger?.focus();
|
||||
};
|
||||
}, [showShortcuts]);
|
||||
|
||||
const commitJumpFrame = useCallback(() => {
|
||||
if (disabled) return;
|
||||
@@ -141,6 +156,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
||||
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
||||
<Tooltip label="Shortcuts and tools">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setShowShortcuts((v) => !v)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
@@ -169,11 +185,14 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
||||
{showShortcuts && (
|
||||
<div
|
||||
id={shortcutsPanelId}
|
||||
ref={panelBodyRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Keyboard shortcuts and tools"
|
||||
// Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
|
||||
// not trapped and the rest of the editor stays operable, so claiming
|
||||
// modality would make assistive tech treat the whole app as inert.
|
||||
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
||||
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto outline-none"
|
||||
style={{
|
||||
background: "#161618",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, memo } from "react";
|
||||
import { useState, useCallback, memo } from "react";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
import { Tooltip } from "../../components/ui";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
||||
|
||||
@@ -16,23 +17,10 @@ export const SpeedMenu = memo(function SpeedMenu({
|
||||
disabled,
|
||||
}: SpeedMenuProps) {
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSpeedMenu) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
speedMenuContainerRef.current &&
|
||||
!speedMenuContainerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowSpeedMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, [showSpeedMenu]);
|
||||
const closeMenu = useCallback(() => setShowSpeedMenu(false), []);
|
||||
// Ref on the container (trigger + menu) so trigger clicks toggle instead of
|
||||
// close-then-reopen; Escape also dismisses.
|
||||
const speedMenuContainerRef = useContextMenuDismiss(closeMenu);
|
||||
|
||||
return (
|
||||
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
||||
@@ -41,6 +29,9 @@ export const SpeedMenu = memo(function SpeedMenu({
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSpeedMenu}
|
||||
aria-label="Playback speed"
|
||||
className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
|
||||
>
|
||||
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
||||
@@ -48,33 +39,34 @@ export const SpeedMenu = memo(function SpeedMenu({
|
||||
</Tooltip>
|
||||
{showSpeedMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Playback speed options"
|
||||
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
|
||||
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
{SPEED_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "speed_change", rate });
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
|
||||
style={{
|
||||
color: rate === playbackRate ? "#FAFAFA" : "#71717A",
|
||||
background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (rate !== playbackRate)
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
))}
|
||||
{SPEED_OPTIONS.map((rate) => {
|
||||
const isCurrent = rate === playbackRate;
|
||||
return (
|
||||
<button
|
||||
key={rate}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={isCurrent}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "speed_change", rate });
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors outline-none focus-visible:bg-white/[0.04] ${
|
||||
isCurrent
|
||||
? "text-neutral-50 bg-white/[0.06]"
|
||||
: "text-neutral-500 hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { ClipContextMenu } from "./ClipContextMenu";
|
||||
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
||||
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
||||
import { copyTextToClipboard } from "../../utils/clipboard";
|
||||
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
||||
|
||||
export interface ClipContextMenuState {
|
||||
x: number;
|
||||
@@ -195,6 +197,38 @@ export function TimelineOverlays({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
// Routed to the same focused-ease-segment path a segment click takes,
|
||||
// so the menu advertises the editor that exists rather than growing a
|
||||
// second one. Offered only for a keyframe that names a tween to focus.
|
||||
onEditEase={
|
||||
kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
|
||||
? (elementId, keyframe) => {
|
||||
if (
|
||||
keyframe.animationId === undefined ||
|
||||
keyframe.tweenPercentage === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
usePlayerStore.getState().setFocusedEaseSegment({
|
||||
animationId: keyframe.animationId,
|
||||
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
||||
tweenPercentage: keyframe.tweenPercentage,
|
||||
elementId,
|
||||
});
|
||||
trackStudioSegmentEaseEdit({ action: "open" });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onCopyProperties={(elementId, keyframe) => {
|
||||
const entry = usePlayerStore.getState().keyframeCache.get(elementId);
|
||||
// Tolerance match on clip-%, the same basis the cache is keyed on —
|
||||
// an exact float compare misses a keyframe the menu just opened over.
|
||||
const kf = entry?.keyframes.find(
|
||||
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
|
||||
);
|
||||
if (!kf) return false;
|
||||
return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -110,13 +110,18 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
||||
)}
|
||||
{snapshot.status === "loading" && urls.length === 0 && (
|
||||
<div
|
||||
className="absolute inset-0 animate-pulse"
|
||||
className="absolute inset-0 animate-pulse motion-reduce:animate-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{snapshot.status === "error" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
|
||||
<span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">no preview</span>
|
||||
</div>
|
||||
)}
|
||||
{label && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* APG menu keyboard basics for the timeline context menus: focuses the first
|
||||
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and restores
|
||||
* focus to the previously focused element when the menu unmounts. Pair with
|
||||
* `role="menu"` on the container and `role="menuitem"` on the buttons
|
||||
* (dismiss/Escape handling stays in useContextMenuDismiss).
|
||||
*/
|
||||
export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
|
||||
useEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
const previouslyFocused = document.activeElement;
|
||||
|
||||
const items = () =>
|
||||
Array.from(menu.querySelectorAll<HTMLButtonElement>('[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]);
|
||||
}
|
||||
Reference in New Issue
Block a user