diff --git a/packages/studio/src/player/components/AudioWaveform.tsx b/packages/studio/src/player/components/AudioWaveform.tsx index b6f10bf05..e31873495 100644 --- a/packages/studio/src/player/components/AudioWaveform.tsx +++ b/packages/studio/src/player/components/AudioWaveform.tsx @@ -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 { - 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 { @@ -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" && ( +
+
+ + waveform unavailable + +
+ )} {label && (
( - '[title="Drag to move · double-click to delete"]', - ); + const beat = document.querySelector('[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('[title="Drag to move · double-click to delete"]'), + document.querySelectorAll('[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( - '[title="Drag to move · double-click to delete"]', + '[title="Drag to move · ⌥-click to delete"]', ); act(() => { beats[1]?.dispatchEvent( diff --git a/packages/studio/src/player/components/BeatStrip.tsx b/packages/studio/src/player/components/BeatStrip.tsx index cbc647527..596d086db 100644 --- a/packages/studio/src/player/components/BeatStrip.tsx +++ b/packages/studio/src/player/components/BeatStrip.tsx @@ -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({
{ + 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 diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 68372db95..4a448aee7 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -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(
@@ -51,7 +55,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({ <>
{/* Action */} + {copyError && ( +

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

+ )}
)} + {onEditEase && ( + + )} + + {onCopyProperties && ( + + )} + {/* Delete */} {onDelete && ( )} + {/* Deleting every keyframe sat adjacent to the single delete and styled + identically. Separate and mark it so the two cannot be misread. */} +
+ +
+ + )} +
)} {previewError && ( diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index d36336e41..189ed5f28 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -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(null); + const triggerRef = useRef(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({
- ))} + {SPEED_OPTIONS.map((rate) => { + const isCurrent = rate === playbackRate; + return ( + + ); + })}
)}
diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx index 6e4a83988..c9335f5c2 100644 --- a/packages/studio/src/player/components/TimelineOverlays.tsx +++ b/packages/studio/src/player/components/TimelineOverlays.tsx @@ -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)); + }} /> )} diff --git a/packages/studio/src/player/components/VideoThumbnail.tsx b/packages/studio/src/player/components/VideoThumbnail.tsx index d8b4cb109..1b056b768 100644 --- a/packages/studio/src/player/components/VideoThumbnail.tsx +++ b/packages/studio/src/player/components/VideoThumbnail.tsx @@ -110,13 +110,18 @@ export const VideoThumbnail = memo(function VideoThumbnail({ )} {snapshot.status === "loading" && urls.length === 0 && (
)} + {snapshot.status === "error" && ( +
+ no preview +
+ )} {label && (
): 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]); +}