@hyperframes/studio 0.8.6 → 0.8.7
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-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10336 -7195
- 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 +2 -0
- 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 +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- 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 +7 -7
- 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 +5 -0
- 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.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- 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.ts +150 -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
|
@@ -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 && (
|
|
@@ -6,6 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
|
6
6
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
7
7
|
import { Tooltip } from "../../components/ui";
|
|
8
8
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
9
|
+
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
|
|
9
10
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
10
11
|
import { SpeedMenu } from "./SpeedMenu";
|
|
11
12
|
import { VolumeControl } from "./VolumeControl";
|
|
@@ -155,12 +156,47 @@ const FullscreenButton = memo(function FullscreenButton({
|
|
|
155
156
|
|
|
156
157
|
/* ── Main component ──────────────────────────────────────────────── */
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
|
|
161
|
+
* attribute and never reaches the export — so the state has to say so out loud,
|
|
162
|
+
* or a soloed session reads as a broken mix.
|
|
163
|
+
*/
|
|
164
|
+
const SoloBanner = memo(function SoloBanner({
|
|
165
|
+
previewIframeRef,
|
|
166
|
+
}: {
|
|
167
|
+
previewIframeRef: { current: HTMLIFrameElement | null };
|
|
168
|
+
}) {
|
|
169
|
+
const bannerText = useSoloBannerText(previewIframeRef);
|
|
170
|
+
const clearSolo = usePlayerStore.getState().clearSolo;
|
|
171
|
+
if (bannerText === null) return null;
|
|
172
|
+
return (
|
|
173
|
+
<div
|
|
174
|
+
role="status"
|
|
175
|
+
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
|
|
176
|
+
>
|
|
177
|
+
<span>
|
|
178
|
+
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
|
|
179
|
+
export is not affected
|
|
180
|
+
</span>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={() => clearSolo()}
|
|
184
|
+
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
|
|
185
|
+
>
|
|
186
|
+
Clear
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
158
192
|
interface PlayerControlsProps {
|
|
159
193
|
onTogglePlay: () => void;
|
|
160
194
|
onSeek: (time: number) => void;
|
|
161
195
|
disabled?: boolean;
|
|
162
196
|
isFullscreen?: boolean;
|
|
163
197
|
onToggleFullscreen?: () => void;
|
|
198
|
+
/** Needed to read the soloed clips' labels out of the preview document. */
|
|
199
|
+
previewIframeRef?: { current: HTMLIFrameElement | null };
|
|
164
200
|
}
|
|
165
201
|
|
|
166
202
|
export const PlayerControls = memo(function PlayerControls({
|
|
@@ -169,6 +205,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
169
205
|
disabled = false,
|
|
170
206
|
isFullscreen = false,
|
|
171
207
|
onToggleFullscreen,
|
|
208
|
+
previewIframeRef,
|
|
172
209
|
}: PlayerControlsProps) {
|
|
173
210
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
174
211
|
const duration = usePlayerStore((s) => s.duration);
|
|
@@ -220,73 +257,80 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
220
257
|
});
|
|
221
258
|
|
|
222
259
|
return (
|
|
223
|
-
<div
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
|
260
|
+
<div>
|
|
261
|
+
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
|
|
262
|
+
<div
|
|
263
|
+
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
|
264
|
+
aria-disabled={disabled || undefined}
|
|
265
|
+
style={{
|
|
266
|
+
paddingBottom: "env(safe-area-inset-bottom)",
|
|
267
|
+
}}
|
|
232
268
|
>
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
|
236
|
-
disabled={disabled}
|
|
237
|
-
className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
|
|
269
|
+
<Tooltip
|
|
270
|
+
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
|
238
271
|
>
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
|
|
277
|
+
>
|
|
278
|
+
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
279
|
+
{timeDisplayMode === "time" ? (
|
|
280
|
+
<>
|
|
281
|
+
<span className="mx-0.5 text-neutral-700">/</span>
|
|
282
|
+
<span className="text-neutral-600">{formatTime(duration)}</span>
|
|
283
|
+
</>
|
|
284
|
+
) : null}
|
|
285
|
+
</button>
|
|
286
|
+
</Tooltip>
|
|
248
287
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
288
|
+
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
292
|
+
onClick={() => {
|
|
293
|
+
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
|
294
|
+
onTogglePlay();
|
|
295
|
+
}}
|
|
296
|
+
disabled={controlsDisabled}
|
|
297
|
+
className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
|
298
|
+
>
|
|
299
|
+
<PlayPauseMorphIcon playing={isPlaying} />
|
|
300
|
+
</button>
|
|
301
|
+
</Tooltip>
|
|
263
302
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
303
|
+
<div className="flex min-w-0 items-center justify-self-end">
|
|
304
|
+
<VolumeControl
|
|
305
|
+
audioMuted={audioMuted}
|
|
306
|
+
audioVolume={audioVolume}
|
|
307
|
+
disabled={controlsDisabled}
|
|
308
|
+
setAudioMuted={setAudioMuted}
|
|
309
|
+
setAudioVolume={setAudioVolume}
|
|
310
|
+
/>
|
|
311
|
+
<SpeedMenu
|
|
312
|
+
playbackRate={playbackRate}
|
|
313
|
+
setPlaybackRate={setPlaybackRate}
|
|
314
|
+
disabled={disabled}
|
|
315
|
+
/>
|
|
316
|
+
<LoopButton
|
|
317
|
+
loopEnabled={loopEnabled}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
setLoopEnabled={setLoopEnabled}
|
|
320
|
+
/>
|
|
321
|
+
{onToggleFullscreen && (
|
|
322
|
+
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
|
323
|
+
)}
|
|
324
|
+
<ShortcutsPanel
|
|
325
|
+
disabled={disabled}
|
|
326
|
+
duration={duration}
|
|
327
|
+
inPoint={inPoint}
|
|
328
|
+
outPoint={outPoint}
|
|
329
|
+
setInPoint={setInPoint}
|
|
330
|
+
setOutPoint={setOutPoint}
|
|
331
|
+
onSeek={onSeek}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
290
334
|
</div>
|
|
291
335
|
</div>
|
|
292
336
|
);
|