mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
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>
211 lines
7.3 KiB
TypeScript
211 lines
7.3 KiB
TypeScript
import { useState, useCallback, useMemo, useRef } from "react";
|
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
import { usePlayerStore } from "../store/playerStore";
|
|
import { formatTime } from "../lib/time";
|
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
|
|
interface EditPopoverProps {
|
|
rangeStart: number;
|
|
rangeEnd: number;
|
|
anchorX: number;
|
|
anchorY: number;
|
|
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 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) => {
|
|
const elEnd = el.start + el.duration;
|
|
return el.start < end && elEnd > start;
|
|
});
|
|
}, [elements, start, end]);
|
|
|
|
useMountEffect(() => {
|
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
});
|
|
|
|
useMountEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", handleKey);
|
|
return () => window.removeEventListener("keydown", handleKey);
|
|
});
|
|
|
|
useMountEffect(() => {
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
};
|
|
setTimeout(() => window.addEventListener("mousedown", handleClick), 100);
|
|
return () => window.removeEventListener("mousedown", handleClick);
|
|
});
|
|
|
|
const buildClipboardText = useCallback(() => {
|
|
return buildTimelineAgentPrompt({
|
|
rangeStart: start,
|
|
rangeEnd: end,
|
|
elements: elementsInRange,
|
|
prompt,
|
|
});
|
|
}, [start, end, elementsInRange, prompt]);
|
|
|
|
const handleCopy = useCallback(async () => {
|
|
const copied = await copyTextToClipboard(buildClipboardText());
|
|
if (!copied) {
|
|
setCopyError(true);
|
|
return;
|
|
}
|
|
setCopyError(false);
|
|
writeDraft(storageKey, "");
|
|
setCopiedAgentPrompt(true);
|
|
setTimeout(() => {
|
|
setCopiedAgentPrompt(false);
|
|
onClose();
|
|
}, 800);
|
|
}, [buildClipboardText, onClose, storageKey]);
|
|
|
|
const handleCopyPrompt = useCallback(async () => {
|
|
const promptText = buildPromptCopyText(prompt);
|
|
if (!promptText) return;
|
|
const copied = await copyTextToClipboard(promptText);
|
|
if (!copied) {
|
|
setCopyError(true);
|
|
return;
|
|
}
|
|
setCopyError(false);
|
|
setCopiedPromptOnly(true);
|
|
setTimeout(() => {
|
|
setCopiedPromptOnly(false);
|
|
}, 800);
|
|
}, [prompt]);
|
|
|
|
const style: React.CSSProperties = {
|
|
position: "fixed",
|
|
left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
|
|
top: Math.max(8, anchorY - 280),
|
|
zIndex: 200,
|
|
};
|
|
|
|
return (
|
|
<div ref={popoverRef} style={style}>
|
|
<div className="w-80 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/40 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-studio-accent" />
|
|
<span className="text-[11px] font-medium text-neutral-300">
|
|
{formatTime(start)} — {formatTime(end)}
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] text-neutral-600">
|
|
{elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Elements */}
|
|
{elementsInRange.length > 0 && (
|
|
<div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
|
|
{elementsInRange.map((el) => (
|
|
<div key={el.id} className="flex items-center justify-between py-0.5">
|
|
<span className="text-[10px] font-mono text-studio-accent/80">#{el.id}</span>
|
|
<span className="text-[10px] text-neutral-600">{el.tag}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Prompt */}
|
|
<div className="p-3">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleCopy();
|
|
}
|
|
}}
|
|
placeholder="What should change?"
|
|
rows={2}
|
|
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
|
|
/>
|
|
</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}
|
|
disabled={!buildPromptCopyText(prompt)}
|
|
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all border ${
|
|
copiedPromptOnly
|
|
? "bg-green-500/20 text-green-400 border-green-500/30"
|
|
: "bg-neutral-800/70 text-neutral-200 border-neutral-700/50 hover:bg-neutral-800"
|
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
>
|
|
{copiedPromptOnly ? "Prompt Copied!" : "Copy Prompt"}
|
|
</button>
|
|
<button
|
|
onClick={handleCopy}
|
|
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
|
copiedAgentPrompt
|
|
? "bg-green-500/20 text-green-400 border border-green-500/30"
|
|
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
|
|
}`}
|
|
>
|
|
{copiedAgentPrompt ? "Copied!" : "Copy to Agent"}
|
|
{!copiedAgentPrompt && (
|
|
<span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|