@hyperframes/studio 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -74,9 +74,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function firstBeat(): HTMLDivElement {
|
|
77
|
-
const beat = document.querySelector<HTMLDivElement>(
|
|
78
|
-
'[title="Drag to move · double-click to delete"]',
|
|
79
|
-
);
|
|
77
|
+
const beat = document.querySelector<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]');
|
|
80
78
|
if (!beat) throw new Error("Expected a beat handle");
|
|
81
79
|
return beat;
|
|
82
80
|
}
|
|
@@ -256,9 +254,7 @@ describe("BeatStrip gesture ownership", () => {
|
|
|
256
254
|
);
|
|
257
255
|
});
|
|
258
256
|
|
|
259
|
-
expect(
|
|
260
|
-
document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
|
|
261
|
-
).toHaveLength(2);
|
|
257
|
+
expect(document.querySelectorAll('[title="Drag to move · ⌥-click to delete"]')).toHaveLength(2);
|
|
262
258
|
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
|
|
263
259
|
|
|
264
260
|
act(() => {
|
|
@@ -297,10 +293,10 @@ describe("BeatStrip gesture ownership", () => {
|
|
|
297
293
|
});
|
|
298
294
|
|
|
299
295
|
const lefts = Array.from(
|
|
300
|
-
document.querySelectorAll<HTMLDivElement>('[title="Drag to move ·
|
|
296
|
+
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]'),
|
|
301
297
|
(beat) => beat.style.left,
|
|
302
298
|
);
|
|
303
|
-
expect(lefts).toContain("
|
|
299
|
+
expect(lefts).toContain("128px");
|
|
304
300
|
|
|
305
301
|
releaseBeatDrag(140);
|
|
306
302
|
expectCommittedBeatAt(1.4);
|
|
@@ -310,7 +306,7 @@ describe("BeatStrip gesture ownership", () => {
|
|
|
310
306
|
mountBeatStrip();
|
|
311
307
|
startBeatDrag();
|
|
312
308
|
const beats = document.querySelectorAll<HTMLDivElement>(
|
|
313
|
-
'[title="Drag to move ·
|
|
309
|
+
'[title="Drag to move · ⌥-click to delete"]',
|
|
314
310
|
);
|
|
315
311
|
act(() => {
|
|
316
312
|
beats[1]?.dispatchEvent(
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
15
15
|
|
|
16
16
|
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
17
|
-
const BEAT_HIT_W =
|
|
17
|
+
const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
|
|
18
18
|
|
|
19
19
|
interface BeatDragActor {
|
|
20
20
|
readonly pointerId: number;
|
|
@@ -355,8 +355,9 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
|
|
355
355
|
|
|
356
356
|
/**
|
|
357
357
|
* Green beat dots on the music track's row. Drag a dot to move its beat,
|
|
358
|
-
*
|
|
359
|
-
* beat
|
|
358
|
+
* ⌥-click to delete (kept off double-click so a stuttered drag can't destroy
|
|
359
|
+
* a beat); both scrub the audio. Dot size/brightness scale with beat loudness
|
|
360
|
+
* (gamma-curved for contrast). Deletes remain undoable via ⌘Z.
|
|
360
361
|
*/
|
|
361
362
|
export const BeatStrip = memo(function BeatStrip({
|
|
362
363
|
beatTimes,
|
|
@@ -412,7 +413,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
412
413
|
<div
|
|
413
414
|
key={`${t}-${i}`}
|
|
414
415
|
className="absolute select-none"
|
|
415
|
-
title="Drag to move ·
|
|
416
|
+
title="Drag to move · ⌥-click to delete"
|
|
416
417
|
draggable={false}
|
|
417
418
|
style={{
|
|
418
419
|
left: x - BEAT_HIT_W / 2,
|
|
@@ -428,9 +429,15 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
428
429
|
// selection (which otherwise "selects" the whole panel mid-drag).
|
|
429
430
|
e.preventDefault();
|
|
430
431
|
e.stopPropagation();
|
|
432
|
+
// ⌥ starts no drag: the delete lands on release below, so a
|
|
433
|
+
// slipped ⌥-drag abandons instead of destroying the beat.
|
|
434
|
+
if (e.altKey) return;
|
|
431
435
|
beginBeatDrag(e, t, pps);
|
|
432
436
|
}}
|
|
433
|
-
|
|
437
|
+
onClick={(e) => {
|
|
438
|
+
if (!e.altKey) return;
|
|
439
|
+
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
|
|
440
|
+
// attempt reads as a double-click and would destroy the beat.
|
|
434
441
|
e.stopPropagation();
|
|
435
442
|
deleteBeatAtCompositionTime(t);
|
|
436
443
|
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
|
|
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
|
+
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
6
7
|
|
|
7
8
|
interface ClipContextMenuProps {
|
|
8
9
|
x: number;
|
|
@@ -24,6 +25,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
24
25
|
onDelete,
|
|
25
26
|
}: ClipContextMenuProps) {
|
|
26
27
|
const menuRef = useContextMenuDismiss(onClose);
|
|
28
|
+
useMenuKeyboardNav(menuRef);
|
|
27
29
|
|
|
28
30
|
const menuWidth = 200;
|
|
29
31
|
const menuHeight = 80;
|
|
@@ -44,6 +46,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
44
46
|
return createPortal(
|
|
45
47
|
<div
|
|
46
48
|
ref={menuRef}
|
|
49
|
+
role="menu"
|
|
50
|
+
aria-label="Clip actions"
|
|
47
51
|
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
48
52
|
style={{ left: adjustedX, top: adjustedY }}
|
|
49
53
|
>
|
|
@@ -51,7 +55,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
51
55
|
<>
|
|
52
56
|
<button
|
|
53
57
|
type="button"
|
|
54
|
-
|
|
58
|
+
role="menuitem"
|
|
59
|
+
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
|
|
55
60
|
canSplit
|
|
56
61
|
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
57
62
|
: "text-neutral-600 cursor-not-allowed"
|
|
@@ -73,7 +78,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
73
78
|
|
|
74
79
|
<button
|
|
75
80
|
type="button"
|
|
76
|
-
|
|
81
|
+
role="menuitem"
|
|
82
|
+
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"
|
|
77
83
|
onClick={() => {
|
|
78
84
|
onDelete(element);
|
|
79
85
|
onClose();
|
|
@@ -13,17 +13,44 @@ interface EditPopoverProps {
|
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function draftKey(start: number, end: number): string {
|
|
17
|
+
return `hf-edit-draft:${start.toFixed(2)}:${end.toFixed(2)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readDraft(key: string): string {
|
|
21
|
+
try {
|
|
22
|
+
return sessionStorage.getItem(key) ?? "";
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeDraft(key: string, value: string): void {
|
|
29
|
+
try {
|
|
30
|
+
if (value) sessionStorage.setItem(key, value);
|
|
31
|
+
else sessionStorage.removeItem(key);
|
|
32
|
+
} catch {
|
|
33
|
+
/* storage unavailable — draft persistence degrades gracefully */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
|
17
38
|
const elements = usePlayerStore((s) => s.elements);
|
|
18
|
-
const
|
|
39
|
+
const start = Math.min(rangeStart, rangeEnd);
|
|
40
|
+
const end = Math.max(rangeStart, rangeEnd);
|
|
41
|
+
// Persist the typed prompt per range so Escape/outside-click doesn't destroy it.
|
|
42
|
+
const storageKey = draftKey(start, end);
|
|
43
|
+
const [prompt, setPromptState] = useState(() => readDraft(storageKey));
|
|
44
|
+
const setPrompt = (value: string) => {
|
|
45
|
+
setPromptState(value);
|
|
46
|
+
writeDraft(storageKey, value);
|
|
47
|
+
};
|
|
19
48
|
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
20
49
|
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
|
|
50
|
+
const [copyError, setCopyError] = useState(false);
|
|
21
51
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
22
52
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
23
53
|
|
|
24
|
-
const start = Math.min(rangeStart, rangeEnd);
|
|
25
|
-
const end = Math.max(rangeStart, rangeEnd);
|
|
26
|
-
|
|
27
54
|
const elementsInRange = useMemo(() => {
|
|
28
55
|
return elements.filter((el) => {
|
|
29
56
|
const elEnd = el.start + el.duration;
|
|
@@ -64,19 +91,28 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
64
91
|
|
|
65
92
|
const handleCopy = useCallback(async () => {
|
|
66
93
|
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
-
if (!copied)
|
|
94
|
+
if (!copied) {
|
|
95
|
+
setCopyError(true);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setCopyError(false);
|
|
99
|
+
writeDraft(storageKey, "");
|
|
68
100
|
setCopiedAgentPrompt(true);
|
|
69
101
|
setTimeout(() => {
|
|
70
102
|
setCopiedAgentPrompt(false);
|
|
71
103
|
onClose();
|
|
72
104
|
}, 800);
|
|
73
|
-
}, [buildClipboardText, onClose]);
|
|
105
|
+
}, [buildClipboardText, onClose, storageKey]);
|
|
74
106
|
|
|
75
107
|
const handleCopyPrompt = useCallback(async () => {
|
|
76
108
|
const promptText = buildPromptCopyText(prompt);
|
|
77
109
|
if (!promptText) return;
|
|
78
110
|
const copied = await copyTextToClipboard(promptText);
|
|
79
|
-
if (!copied)
|
|
111
|
+
if (!copied) {
|
|
112
|
+
setCopyError(true);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setCopyError(false);
|
|
80
116
|
setCopiedPromptOnly(true);
|
|
81
117
|
setTimeout(() => {
|
|
82
118
|
setCopiedPromptOnly(false);
|
|
@@ -137,6 +173,11 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
137
173
|
</div>
|
|
138
174
|
|
|
139
175
|
{/* Action */}
|
|
176
|
+
{copyError && (
|
|
177
|
+
<p className="px-3 pb-2 text-[10px] text-red-400" role="alert">
|
|
178
|
+
Copy failed — check clipboard permissions and try again.
|
|
179
|
+
</p>
|
|
180
|
+
)}
|
|
140
181
|
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
|
|
141
182
|
<button
|
|
142
183
|
onClick={handleCopyPrompt}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { createPortal } from "react-dom";
|
|
2
3
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
|
+
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
3
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
6
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
5
7
|
|
|
@@ -25,45 +27,83 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
25
27
|
* worse than no entry. */
|
|
26
28
|
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
27
29
|
onDeleteAll: (element: TimelineElement, animationId?: string) => void;
|
|
30
|
+
/** Focus this keyframe's ease segment in the inspector. Omitted when the
|
|
31
|
+
* keyframe carries no tween identity to focus. */
|
|
32
|
+
onEditEase?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
33
|
+
/** Copy the keyframe's properties to the clipboard; resolves false on failure. */
|
|
34
|
+
onCopyProperties?: (
|
|
35
|
+
elementId: string,
|
|
36
|
+
keyframe: TimelineKeyframeTarget,
|
|
37
|
+
) => Promise<boolean> | boolean | void;
|
|
28
38
|
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
29
39
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
const ITEM_CLS =
|
|
43
|
+
"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";
|
|
44
|
+
const DESTRUCTIVE_ITEM_CLS =
|
|
45
|
+
"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";
|
|
46
|
+
|
|
32
47
|
export function KeyframeDiamondContextMenu({
|
|
33
48
|
state,
|
|
34
49
|
onClose,
|
|
35
50
|
onDelete,
|
|
36
51
|
onDeleteAll,
|
|
52
|
+
onEditEase,
|
|
53
|
+
onCopyProperties,
|
|
37
54
|
onMoveToPlayhead,
|
|
38
55
|
}: KeyframeDiamondContextMenuProps) {
|
|
39
56
|
const menuRef = useContextMenuDismiss(onClose);
|
|
40
|
-
// The clicked diamond's identity, built once: the menu's
|
|
41
|
-
//
|
|
57
|
+
// The clicked diamond's identity, built once: the menu's mutating entries
|
|
58
|
+
// all act on it, and they must not disagree about which keyframe was clicked.
|
|
42
59
|
const keyframe: TimelineKeyframeTarget = {
|
|
43
60
|
percentage: state.percentage,
|
|
44
61
|
tweenPercentage: state.tweenPercentage,
|
|
45
62
|
propertyGroup: state.propertyGroup,
|
|
46
63
|
animationId: state.animationId,
|
|
47
64
|
};
|
|
65
|
+
useMenuKeyboardNav(menuRef);
|
|
66
|
+
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
|
|
67
|
+
|
|
68
|
+
const handleCopyProperties = async () => {
|
|
69
|
+
if (!onCopyProperties) return;
|
|
70
|
+
const result = await onCopyProperties(state.elementId, keyframe);
|
|
71
|
+
if (result === false) {
|
|
72
|
+
setCopyStatus("failed");
|
|
73
|
+
setTimeout(() => setCopyStatus("idle"), 1500);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setCopyStatus("copied");
|
|
77
|
+
setTimeout(onClose, 700);
|
|
78
|
+
};
|
|
48
79
|
|
|
49
80
|
const menuWidth = 200;
|
|
50
81
|
// Measured off the rendered rows, so the flip-up test below stays right as
|
|
51
|
-
// optional entries drop out.
|
|
52
|
-
const
|
|
82
|
+
// optional entries drop out. The separator counts as roughly a third of a row.
|
|
83
|
+
const rows =
|
|
84
|
+
1 +
|
|
85
|
+
(onMoveToPlayhead ? 1 : 0) +
|
|
86
|
+
(onEditEase ? 1 : 0) +
|
|
87
|
+
(onCopyProperties ? 1 : 0) +
|
|
88
|
+
(onDelete ? 1 : 0);
|
|
89
|
+
const menuHeight = 10 + rows * 30 + 9;
|
|
53
90
|
const overflowY = state.y + menuHeight - window.innerHeight;
|
|
54
91
|
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
|
55
|
-
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
|
92
|
+
const adjustedY = Math.max(8, overflowY > 0 ? state.y - overflowY - 8 : state.y);
|
|
56
93
|
|
|
57
94
|
return createPortal(
|
|
58
95
|
<div
|
|
59
96
|
ref={menuRef}
|
|
60
|
-
|
|
61
|
-
|
|
97
|
+
role="menu"
|
|
98
|
+
aria-label="Keyframe actions"
|
|
99
|
+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
|
|
100
|
+
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
|
|
62
101
|
>
|
|
63
102
|
{onMoveToPlayhead && (
|
|
64
103
|
<button
|
|
65
104
|
type="button"
|
|
66
|
-
|
|
105
|
+
role="menuitem"
|
|
106
|
+
className={ITEM_CLS}
|
|
67
107
|
onClick={() => {
|
|
68
108
|
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
|
69
109
|
// and returns the tween-% for the mutation. Passing tween-% here would
|
|
@@ -76,11 +116,44 @@ export function KeyframeDiamondContextMenu({
|
|
|
76
116
|
</button>
|
|
77
117
|
)}
|
|
78
118
|
|
|
119
|
+
{onEditEase && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
role="menuitem"
|
|
123
|
+
className={`${ITEM_CLS} justify-between`}
|
|
124
|
+
onClick={() => {
|
|
125
|
+
onEditEase(state.elementId, keyframe);
|
|
126
|
+
onClose();
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<span>Edit Ease…</span>
|
|
130
|
+
<span className="text-[10px] text-neutral-500">{state.currentEase ?? "default"}</span>
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{onCopyProperties && (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
role="menuitem"
|
|
138
|
+
className={ITEM_CLS}
|
|
139
|
+
onClick={() => {
|
|
140
|
+
void handleCopyProperties();
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{copyStatus === "copied"
|
|
144
|
+
? "Copied!"
|
|
145
|
+
: copyStatus === "failed"
|
|
146
|
+
? "Copy failed — check permissions"
|
|
147
|
+
: "Copy Properties"}
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
|
|
79
151
|
{/* Delete */}
|
|
80
152
|
{onDelete && (
|
|
81
153
|
<button
|
|
82
154
|
type="button"
|
|
83
|
-
|
|
155
|
+
role="menuitem"
|
|
156
|
+
className={DESTRUCTIVE_ITEM_CLS}
|
|
84
157
|
onClick={() => {
|
|
85
158
|
onDelete(state.elementId, keyframe);
|
|
86
159
|
onClose();
|
|
@@ -90,9 +163,16 @@ export function KeyframeDiamondContextMenu({
|
|
|
90
163
|
</button>
|
|
91
164
|
)}
|
|
92
165
|
|
|
166
|
+
{/* Deleting every keyframe sat adjacent to the single delete and styled
|
|
167
|
+
identically. Separate and mark it so the two cannot be misread. */}
|
|
168
|
+
<div className="my-1 border-t border-neutral-700/60" role="separator" />
|
|
169
|
+
|
|
170
|
+
<div className="my-1 border-t border-neutral-700/60" role="separator" />
|
|
171
|
+
|
|
93
172
|
<button
|
|
94
173
|
type="button"
|
|
95
|
-
|
|
174
|
+
role="menuitem"
|
|
175
|
+
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"
|
|
96
176
|
onClick={() => {
|
|
97
177
|
onDeleteAll(state.element, state.animationId);
|
|
98
178
|
onClose();
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { CaretRight } from "@phosphor-icons/react";
|
|
2
1
|
import { TRACK_H } from "./timelineLayout";
|
|
3
2
|
import { TrackClipCount } from "./TrackClipCount";
|
|
4
3
|
|
|
5
|
-
// Layer row (Figma order: disclosure
|
|
6
|
-
// here, not on the clip bar, and re-expands a collapsed layer.
|
|
4
|
+
// Layer row (Figma order: disclosure ∿, diamond, name) — the disclosure lives
|
|
5
|
+
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
|
|
6
|
+
// caret) because a group's own row keeps the caret for its structural
|
|
7
|
+
// disclosure (member rows) — this button only ever means "show this row's
|
|
8
|
+
// lanes", so it needs its own distinct glyph.
|
|
7
9
|
export function LayerDisclosureRow({
|
|
8
10
|
name,
|
|
9
11
|
clipCount,
|
|
@@ -49,23 +51,20 @@ export function LayerDisclosureRow({
|
|
|
49
51
|
tabIndex={-1}
|
|
50
52
|
aria-expanded={isExpanded}
|
|
51
53
|
aria-controls={lanesId}
|
|
52
|
-
aria-label={`${isExpanded ? "
|
|
53
|
-
title={`${isExpanded ? "
|
|
54
|
-
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The
|
|
54
|
+
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
|
|
55
|
+
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
56
|
+
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
55
57
|
// only the hit box grows.
|
|
56
|
-
className=
|
|
58
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
59
|
+
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
60
|
+
}`}
|
|
57
61
|
onPointerDown={(event) => event.stopPropagation()}
|
|
58
62
|
onClick={(event) => {
|
|
59
63
|
event.stopPropagation();
|
|
60
64
|
onToggleClipExpanded();
|
|
61
65
|
}}
|
|
62
66
|
>
|
|
63
|
-
<
|
|
64
|
-
size={11}
|
|
65
|
-
weight="bold"
|
|
66
|
-
aria-hidden="true"
|
|
67
|
-
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
|
68
|
-
/>
|
|
67
|
+
<span aria-hidden="true">∿</span>
|
|
69
68
|
</button>
|
|
70
69
|
{/* Decorative: the disclosure button above already names the row's keyframe
|
|
71
70
|
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
|
@@ -135,6 +135,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
135
135
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
136
136
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
137
137
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
138
|
+
const [assetWaitLong, setAssetWaitLong] = useState(false);
|
|
138
139
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
139
140
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
140
141
|
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
|
@@ -236,6 +237,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
236
237
|
attempts += 1;
|
|
237
238
|
lastUnloaded = hasUnloadedAssets(iframe, lastUnloaded);
|
|
238
239
|
if (!lastUnloaded || attempts > 100) {
|
|
240
|
+
if (lastUnloaded && attempts > 100) {
|
|
241
|
+
console.debug(
|
|
242
|
+
"[studio] asset readiness poll hit the 10s cap — continuing with unloaded assets",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
239
245
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
240
246
|
assetPollRef.current = null;
|
|
241
247
|
setAssetsLoading(false);
|
|
@@ -327,6 +333,17 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
327
333
|
};
|
|
328
334
|
});
|
|
329
335
|
|
|
336
|
+
// Surface a "Continue anyway" escape hatch once the asset wait drags on.
|
|
337
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (!assetsLoading) {
|
|
340
|
+
setAssetWaitLong(false);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const timer = setTimeout(() => setAssetWaitLong(true), 3000);
|
|
344
|
+
return () => clearTimeout(timer);
|
|
345
|
+
}, [assetsLoading]);
|
|
346
|
+
|
|
330
347
|
useEffect(() => {
|
|
331
348
|
if (assetFadeRef.current) {
|
|
332
349
|
clearTimeout(assetFadeRef.current);
|
|
@@ -354,12 +371,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
354
371
|
};
|
|
355
372
|
}, [assetsLoading]);
|
|
356
373
|
|
|
374
|
+
const handleContinueAnyway = () => {
|
|
375
|
+
if (assetPollRef.current) {
|
|
376
|
+
clearInterval(assetPollRef.current);
|
|
377
|
+
assetPollRef.current = null;
|
|
378
|
+
}
|
|
379
|
+
setAssetsLoading(false);
|
|
380
|
+
};
|
|
381
|
+
|
|
357
382
|
const showCompositionOverlay =
|
|
358
383
|
!suppressLoadingOverlay &&
|
|
359
384
|
!compositionOverlayDeferred &&
|
|
360
385
|
shouldShowCompositionLoadingOverlay(compositionLoading);
|
|
361
386
|
const showAssetOverlay =
|
|
362
|
-
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
|
|
387
|
+
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay && !previewError;
|
|
363
388
|
|
|
364
389
|
useEffect(() => {
|
|
365
390
|
onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay);
|
|
@@ -396,17 +421,27 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
396
421
|
style={{
|
|
397
422
|
opacity: assetOverlayFading ? 0 : 1,
|
|
398
423
|
pointerEvents: assetOverlayFading ? "none" : "auto",
|
|
399
|
-
transition: "opacity
|
|
424
|
+
transition: "opacity 180ms ease-in",
|
|
400
425
|
}}
|
|
401
426
|
onDragStart={(event) => event.preventDefault()}
|
|
402
427
|
onMouseDown={(event) => event.preventDefault()}
|
|
403
|
-
onPointerDown={(event) => event.preventDefault()}
|
|
404
428
|
>
|
|
405
|
-
<
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
429
|
+
<div className="flex flex-col items-center gap-3">
|
|
430
|
+
<HyperframesLoader
|
|
431
|
+
title="Preparing preview assets"
|
|
432
|
+
detail="Waiting for media and motion assets before playback starts."
|
|
433
|
+
size={56}
|
|
434
|
+
/>
|
|
435
|
+
{assetWaitLong && (
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
onClick={handleContinueAnyway}
|
|
439
|
+
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"
|
|
440
|
+
>
|
|
441
|
+
Continue anyway
|
|
442
|
+
</button>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
410
445
|
</div>
|
|
411
446
|
)}
|
|
412
447
|
{previewError && (
|