@hyperframes/studio 0.8.105 → 0.8.107
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-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
7
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
8
|
+
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("undo that reloads the preview", () => {
|
|
18
|
+
it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
|
|
19
|
+
const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
|
|
20
|
+
usePlayerStore.getState().setElements(clips);
|
|
21
|
+
usePlayerStore.getState().setTimelineReady(true);
|
|
22
|
+
const reloadPreview = vi.fn();
|
|
23
|
+
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
|
|
24
|
+
null;
|
|
25
|
+
function Harness() {
|
|
26
|
+
sync = usePreviewPersistence({
|
|
27
|
+
showToast: () => {},
|
|
28
|
+
readOptionalProjectFile: async () => "",
|
|
29
|
+
writeProjectFile: async () => {},
|
|
30
|
+
recordEdit: async () => {},
|
|
31
|
+
previewIframeRef: { current: null },
|
|
32
|
+
activeCompPathRef: { current: "index.html" },
|
|
33
|
+
reloadPreview,
|
|
34
|
+
}).syncHistoryPreviewAfterApply;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
mountReactHarness(<Harness />);
|
|
38
|
+
|
|
39
|
+
// A nested composition's undo is not the active file's, so it reloads the preview.
|
|
40
|
+
const files = { "compositions/sub.html": { previous: "<p>1</p>", restored: "<p>2</p>" } };
|
|
41
|
+
await act(async () => sync!({ paths: Object.keys(files), files }));
|
|
42
|
+
|
|
43
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
44
|
+
const player = usePlayerStore.getState();
|
|
45
|
+
expect([player.elements, player.timelineReady]).toEqual([clips, true]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -159,6 +159,11 @@ export function usePreviewPersistence({
|
|
|
159
159
|
if (result.status !== "clean") throw result.error;
|
|
160
160
|
}, [drainPendingDomEditSaves]);
|
|
161
161
|
|
|
162
|
+
const settlePendingEdits = useCallback(async (): Promise<void> => {
|
|
163
|
+
await flushStudioPendingEdits();
|
|
164
|
+
await domEditSaveQueueRef.current?.waitForIdle();
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
162
167
|
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
|
163
168
|
domEditSaveQueueRef.current?.reset();
|
|
164
169
|
setDomEditSaveQueuePaused(null);
|
|
@@ -207,16 +212,13 @@ export function usePreviewPersistence({
|
|
|
207
212
|
reloadPreview,
|
|
208
213
|
);
|
|
209
214
|
if (strategy === "full") {
|
|
210
|
-
|
|
211
|
-
player.setElements([]);
|
|
212
|
-
player.setSelectedElementId(null);
|
|
213
|
-
player.setTimelineReady(false);
|
|
215
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
214
216
|
return;
|
|
215
217
|
}
|
|
216
218
|
// A soft restore patched the reverted attributes onto the live preview, but the
|
|
217
219
|
// player store keeps its own copy and that copy is what the automation lanes
|
|
218
220
|
// draw — so without this an undone envelope edit stayed invisible until a
|
|
219
|
-
// reload. The full path above
|
|
221
|
+
// reload. The full path above waits for the reloaded preview to report instead.
|
|
220
222
|
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
221
223
|
},
|
|
222
224
|
[previewIframeRef, activeCompPathRef, reloadPreview],
|
|
@@ -256,6 +258,7 @@ export function usePreviewPersistence({
|
|
|
256
258
|
queueDomEditSave,
|
|
257
259
|
drainPendingDomEditSaves,
|
|
258
260
|
waitForPendingDomEditSaves,
|
|
261
|
+
settlePendingEdits,
|
|
259
262
|
domEditSaveQueuePaused,
|
|
260
263
|
resetDomEditSaveQueueBreaker,
|
|
261
264
|
applyCurrentStudioManualEditsToPreview,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import type { TimelineElement } from "../player";
|
|
10
10
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
11
11
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
12
|
-
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
12
|
+
import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
|
|
13
13
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
14
14
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
15
15
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
@@ -135,7 +135,7 @@ export function useRenderClipContent({
|
|
|
135
135
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
136
136
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
137
137
|
if (effectiveMode === "hidden") {
|
|
138
|
-
return el
|
|
138
|
+
return rendersWaveform(el)
|
|
139
139
|
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
|
140
140
|
: null;
|
|
141
141
|
}
|
|
@@ -173,7 +173,7 @@ export function useRenderClipContent({
|
|
|
173
173
|
// Audio clips — waveform visualization. Resolve these before the generic
|
|
174
174
|
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
|
|
175
175
|
// captured frame from the currently drilled composition preview.
|
|
176
|
-
if (el
|
|
176
|
+
if (rendersWaveform(el)) {
|
|
177
177
|
return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
|
|
178
178
|
}
|
|
179
179
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
3
3
|
|
|
4
4
|
type AsyncFn = (...args: never[]) => Promise<unknown>;
|
|
5
5
|
|
|
@@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() {
|
|
|
11
11
|
const key = fn as unknown as AsyncFn;
|
|
12
12
|
const cached = wrappedRef.current.get(key);
|
|
13
13
|
if (cached) return cached as unknown as (...args: Args) => Promise<R>;
|
|
14
|
-
const wrapped = (
|
|
15
|
-
const result = fn(...args);
|
|
16
|
-
trackStudioPendingEdit(result);
|
|
17
|
-
return result;
|
|
18
|
-
};
|
|
14
|
+
const wrapped = trackedStudioEdit(fn);
|
|
19
15
|
wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
|
|
20
16
|
return wrapped;
|
|
21
17
|
}, []);
|
package/src/index.ts
CHANGED
|
@@ -181,6 +181,8 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
|
181
181
|
// DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
|
|
182
182
|
export { useDomEditSession } from "./hooks/useDomEditSession";
|
|
183
183
|
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
|
|
184
|
+
export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
|
|
185
|
+
export type { PreviewPromotion } from "./player/sceneSwap";
|
|
184
186
|
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
185
187
|
export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
|
|
186
188
|
export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
|
|
@@ -238,3 +240,10 @@ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/m
|
|
|
238
240
|
export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
|
|
239
241
|
export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
|
|
240
242
|
export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
|
|
243
|
+
|
|
244
|
+
export { buildStudioTools } from "./webmcp/useStudioAgentTools";
|
|
245
|
+
export type { StudioAgentToolsDeps } from "./webmcp/useStudioAgentTools";
|
|
246
|
+
export { collectStudioLookScene } from "./webmcp/tools/lookTools";
|
|
247
|
+
export type { StudioLookSnapshot } from "./webmcp/tools/lookTools";
|
|
248
|
+
export type { ModelContextTool } from "./webmcp/types";
|
|
249
|
+
export type { ToolResult } from "./webmcp/toolResult";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Reads the preview on screen the way a host app does: by package name.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { expect, expectTypeOf, it } from "vitest";
|
|
6
|
+
import { useLivePreviewIframe, type PreviewPromotion } from "@hyperframes/studio";
|
|
7
|
+
import {
|
|
8
|
+
makePreview,
|
|
9
|
+
mountPlayerWithPreview,
|
|
10
|
+
paintShadow,
|
|
11
|
+
} from "./player/hooks/timelinePlayerTestHarness";
|
|
12
|
+
|
|
13
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
14
|
+
|
|
15
|
+
it("gives a host the preview on screen after a real shadow reload is promoted", async () => {
|
|
16
|
+
expectTypeOf<PreviewPromotion>().toHaveProperty("live");
|
|
17
|
+
const host = makePreview("<h1>Title</h1>");
|
|
18
|
+
const player = mountPlayerWithPreview(host);
|
|
19
|
+
let live: HTMLIFrameElement | null = null;
|
|
20
|
+
function Probe() {
|
|
21
|
+
live = useLivePreviewIframe(host);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
25
|
+
act(() => root.render(<Probe />));
|
|
26
|
+
act(() => player.getApi().refreshPlayer());
|
|
27
|
+
const shadow = makePreview("<h1>Title</h1>", "?_t=1");
|
|
28
|
+
await paintShadow(player.getApi, shadow);
|
|
29
|
+
expect(player.getApi().iframeRef.current).toBe(shadow);
|
|
30
|
+
expect(live).toBe(shadow);
|
|
31
|
+
act(() => root.unmount());
|
|
32
|
+
act(() => player.root.unmount());
|
|
33
|
+
});
|
package/src/main.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
import { IconContext } from "@phosphor-icons/react";
|
|
4
4
|
import { StudioApp } from "./App";
|
|
5
|
+
import { ShowThemeToggle } from "./components/ThemeToggle";
|
|
5
6
|
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
6
7
|
import { readIconTokens } from "./styles/iconTokens";
|
|
7
8
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
@@ -128,7 +129,9 @@ createRoot(document.getElementById("root")!).render(
|
|
|
128
129
|
Icons that pass their own size or weight still win. */}
|
|
129
130
|
<IconContext.Provider value={readIconTokens()}>
|
|
130
131
|
<StudioErrorBoundary>
|
|
131
|
-
<
|
|
132
|
+
<ShowThemeToggle.Provider value>
|
|
133
|
+
<StudioApp />
|
|
134
|
+
</ShowThemeToggle.Provider>
|
|
132
135
|
</StudioErrorBoundary>
|
|
133
136
|
</IconContext.Provider>
|
|
134
137
|
</StrictMode>,
|
|
@@ -20,24 +20,30 @@ afterEach(() => {
|
|
|
20
20
|
document.body.innerHTML = "";
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
/** A 6 x 20 canvas whose 2D context records every fill as [style, x, y, width, height]. */
|
|
24
|
+
function recordingCanvas() {
|
|
25
|
+
const fills: Array<[string, number, number, number, number]> = [];
|
|
26
|
+
const context = {
|
|
27
|
+
scale: vi.fn(),
|
|
28
|
+
clearRect: vi.fn(),
|
|
29
|
+
fillStyle: "",
|
|
30
|
+
fillRect: (x: number, y: number, width: number, height: number) =>
|
|
31
|
+
fills.push([String(context.fillStyle), x, y, width, height]),
|
|
32
|
+
} as unknown as CanvasRenderingContext2D;
|
|
33
|
+
const canvas = document.createElement("canvas");
|
|
34
|
+
Object.defineProperties(canvas, {
|
|
35
|
+
clientWidth: { value: 6 },
|
|
36
|
+
clientHeight: { value: 20 },
|
|
37
|
+
});
|
|
38
|
+
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
|
39
|
+
return { canvas, fills };
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
describe("AudioWaveform", () => {
|
|
24
43
|
it("paints a baseline and peak bar for every mapped waveform bin", () => {
|
|
25
|
-
const
|
|
26
|
-
const context = {
|
|
27
|
-
scale: vi.fn(),
|
|
28
|
-
clearRect: vi.fn(),
|
|
29
|
-
fillRect,
|
|
30
|
-
fillStyle: "",
|
|
31
|
-
} as unknown as CanvasRenderingContext2D;
|
|
32
|
-
const canvas = document.createElement("canvas");
|
|
33
|
-
Object.defineProperties(canvas, {
|
|
34
|
-
clientWidth: { value: 6 },
|
|
35
|
-
clientHeight: { value: 20 },
|
|
36
|
-
});
|
|
37
|
-
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
|
44
|
+
const { canvas, fills } = recordingCanvas();
|
|
38
45
|
drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
|
|
39
|
-
expect(
|
|
40
|
-
expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
46
|
+
expect(fills.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
41
47
|
[0, 18, 3, 2],
|
|
42
48
|
[0, 15, 3, 5],
|
|
43
49
|
[3, 18, 3, 2],
|
|
@@ -45,6 +51,21 @@ describe("AudioWaveform", () => {
|
|
|
45
51
|
]);
|
|
46
52
|
});
|
|
47
53
|
|
|
54
|
+
it("shrinks each bar to the fade's gain and keeps the cut-away part as a ghost", () => {
|
|
55
|
+
const { canvas, fills } = recordingCanvas();
|
|
56
|
+
// A 1 s fade-in on a 2 s clip: the first bar's centre (0.5 s) plays at half gain.
|
|
57
|
+
drawWaveformCanvas(canvas, [1, 1], false, 0, 1, { fadeIn: 1, fadeOut: 0, duration: 2 });
|
|
58
|
+
const bars = fills.filter(([, , y, , height]) => !(y === 18 && height === 2));
|
|
59
|
+
// The heard half, then the ghost only above it, so the two never stack.
|
|
60
|
+
expect(bars.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
61
|
+
[0, 10, 3, 10],
|
|
62
|
+
[0, 0, 3, 10],
|
|
63
|
+
[3, 0, 3, 20],
|
|
64
|
+
]);
|
|
65
|
+
const alpha = (style: string) => Number(style.split(",").at(-1)?.replace(")", ""));
|
|
66
|
+
expect(alpha(bars[1][0])).toBeCloseTo(alpha(bars[0][0]) * 0.27, 2);
|
|
67
|
+
});
|
|
68
|
+
|
|
48
69
|
it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
|
|
49
70
|
const host = document.createElement("div");
|
|
50
71
|
document.body.append(host);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
|
1
|
+
import { memo, useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { fadeGain } from "@hyperframes/core/audio-fade";
|
|
2
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
4
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
5
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
6
|
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
|
7
|
+
import { ClipFadesContext, type ClipFadeShape } from "./TimelineClipFades";
|
|
6
8
|
|
|
7
9
|
export interface AudioWaveformProps {
|
|
8
10
|
audioUrl: string;
|
|
@@ -22,7 +24,10 @@ export interface AudioWaveformProps {
|
|
|
22
24
|
|
|
23
25
|
const BAR_STEP = 3;
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
export const rendersWaveform = (el: { tag: string }) => el.tag === "audio";
|
|
28
|
+
const FADE_GHOST_OPACITY = 0.27;
|
|
29
|
+
|
|
30
|
+
type BarGeometry = { x: number; width: number; height: number; gain: number };
|
|
26
31
|
|
|
27
32
|
function paintWaveformBars(
|
|
28
33
|
context: CanvasRenderingContext2D,
|
|
@@ -33,11 +38,18 @@ function paintWaveformBars(
|
|
|
33
38
|
amplitudes: readonly number[],
|
|
34
39
|
) {
|
|
35
40
|
bars.forEach((bar, index) => {
|
|
36
|
-
const
|
|
41
|
+
const opacity = loudnessToOpacity(amplitudes[index] ?? 0);
|
|
37
42
|
context.fillStyle = `rgb(${waveformBaselineRgb})`;
|
|
38
43
|
context.fillRect(bar.x, height - 2, bar.width, 2);
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
const paint = (alpha: number, top: number, barHeight: number) => {
|
|
45
|
+
context.fillStyle = `rgba(${waveformBarRgb},${alpha.toFixed(2)})`;
|
|
46
|
+
context.fillRect(bar.x, top, bar.width, barHeight);
|
|
47
|
+
};
|
|
48
|
+
const faded = bar.height * bar.gain;
|
|
49
|
+
if (faded > 0) paint(opacity, height - faded, faded);
|
|
50
|
+
if (faded < bar.height) {
|
|
51
|
+
paint(opacity * FADE_GHOST_OPACITY, height - bar.height, bar.height - faded);
|
|
52
|
+
}
|
|
41
53
|
});
|
|
42
54
|
}
|
|
43
55
|
|
|
@@ -47,6 +59,7 @@ export function drawWaveformCanvas(
|
|
|
47
59
|
muted: boolean,
|
|
48
60
|
trimStartFraction: number,
|
|
49
61
|
trimEndFraction: number,
|
|
62
|
+
fades: ClipFadeShape | null = null,
|
|
50
63
|
) {
|
|
51
64
|
const width = Math.max(1, canvas.clientWidth);
|
|
52
65
|
const height = Math.max(1, canvas.clientHeight);
|
|
@@ -67,6 +80,9 @@ export function drawWaveformCanvas(
|
|
|
67
80
|
x: (index * width) / amplitudes.length,
|
|
68
81
|
width: Math.max(1, width / amplitudes.length),
|
|
69
82
|
height: Math.max(3, amplitude * height),
|
|
83
|
+
gain: fades
|
|
84
|
+
? fadeGain(((index + 0.5) / amplitudes.length) * fades.duration, fades.duration, fades)
|
|
85
|
+
: 1,
|
|
70
86
|
}));
|
|
71
87
|
const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
|
|
72
88
|
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
|
|
@@ -179,11 +195,12 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
179
195
|
const peaks =
|
|
180
196
|
snapshot.status === "ready" && snapshot.value.kind === "waveform" ? snapshot.value.peaks : null;
|
|
181
197
|
|
|
198
|
+
const fades = useContext(ClipFadesContext);
|
|
182
199
|
const draw = useCallback(() => {
|
|
183
200
|
const canvas = canvasRef.current;
|
|
184
201
|
if (!canvas || !peaks) return;
|
|
185
|
-
drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
|
|
186
|
-
}, [muted, peaks, trimEndFraction, trimStartFraction]);
|
|
202
|
+
drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1, fades);
|
|
203
|
+
}, [fades, muted, peaks, trimEndFraction, trimStartFraction]);
|
|
187
204
|
|
|
188
205
|
const setCanvasRef = useCallback(
|
|
189
206
|
(canvas: HTMLCanvasElement | null) => {
|
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
getTimelineScrollLeftForZoomAnchor,
|
|
17
17
|
getTimelineScrollLeftForZoomTransition,
|
|
18
18
|
shouldShowTimelineShortcutHint,
|
|
19
|
-
shouldHandleTimelineDeleteKey,
|
|
20
19
|
shouldAutoScrollTimeline,
|
|
21
20
|
getTimelineVisibleTimeRange,
|
|
22
21
|
getTimelineScrollTopForGeometryChange,
|
|
@@ -1248,26 +1247,6 @@ describe("shouldShowTimelineShortcutHint", () => {
|
|
|
1248
1247
|
});
|
|
1249
1248
|
});
|
|
1250
1249
|
|
|
1251
|
-
describe("shouldHandleTimelineDeleteKey", () => {
|
|
1252
|
-
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
1253
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
1254
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
it("ignores modifier shortcuts", () => {
|
|
1258
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
|
1259
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
it("ignores input and editable targets", () => {
|
|
1263
|
-
const input = { tagName: "INPUT", isContentEditable: false };
|
|
1264
|
-
const editable = { tagName: "DIV", isContentEditable: true };
|
|
1265
|
-
|
|
1266
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
|
1267
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
|
1268
|
-
});
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
1250
|
describe("getDefaultDroppedTrack", () => {
|
|
1272
1251
|
it("defaults to track 0 when there are no rows yet", () => {
|
|
1273
1252
|
expect(getDefaultDroppedTrack([])).toBe(0);
|
|
@@ -26,7 +26,6 @@ export {
|
|
|
26
26
|
getTimelineCanvasHeight,
|
|
27
27
|
shouldShowTimelineShortcutHint,
|
|
28
28
|
resolveTimelineAssetDrop,
|
|
29
|
-
shouldHandleTimelineDeleteKey,
|
|
30
29
|
getDefaultDroppedTrack,
|
|
31
30
|
} from "./timelineLayout";
|
|
32
31
|
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
|
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { TimelineClip } from "./TimelineClip";
|
|
8
|
+
import { ClipFadesContext } from "./TimelineClipFades";
|
|
8
9
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
9
10
|
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
|
10
11
|
|
|
@@ -67,6 +68,43 @@ function renderClip({
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
describe("TimelineClip", () => {
|
|
71
|
+
it("hands its content the clip's fades, and none to a clip the mixer does not hear", () => {
|
|
72
|
+
const seen: unknown[] = [];
|
|
73
|
+
function Probe() {
|
|
74
|
+
seen.push(React.useContext(ClipFadesContext));
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const render = (element: TimelineElement) => {
|
|
78
|
+
const host = document.createElement("div");
|
|
79
|
+
document.body.append(host);
|
|
80
|
+
const root = createRoot(host);
|
|
81
|
+
act(() => {
|
|
82
|
+
root.render(
|
|
83
|
+
<TimelineClip
|
|
84
|
+
el={element}
|
|
85
|
+
pps={100}
|
|
86
|
+
clipY={0}
|
|
87
|
+
isSelected={false}
|
|
88
|
+
isHovered={false}
|
|
89
|
+
hasCustomContent
|
|
90
|
+
capabilities={capabilities}
|
|
91
|
+
isComposition={false}
|
|
92
|
+
onHoverStart={vi.fn()}
|
|
93
|
+
onHoverEnd={vi.fn()}
|
|
94
|
+
onClick={vi.fn()}
|
|
95
|
+
onDoubleClick={vi.fn()}
|
|
96
|
+
>
|
|
97
|
+
<Probe />
|
|
98
|
+
</TimelineClip>,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
act(() => root.unmount());
|
|
102
|
+
};
|
|
103
|
+
render({ id: "music", tag: "audio", start: 0, duration: 4, track: 0, fadeIn: 1, fadeOut: 0.5 });
|
|
104
|
+
render({ id: "title", tag: "div", start: 0, duration: 4, track: 0, fadeIn: 1 });
|
|
105
|
+
expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
|
|
106
|
+
});
|
|
107
|
+
|
|
70
108
|
it("renders the clip label above custom content without showing default timecode", () => {
|
|
71
109
|
const { host, root } = renderClip({
|
|
72
110
|
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
10
10
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
11
11
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
12
|
-
import { TimelineClipFades } from "./TimelineClipFades";
|
|
12
|
+
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
|
|
13
|
+
import { rendersWaveform } from "./AudioWaveform";
|
|
13
14
|
|
|
14
15
|
interface TimelineClipProps {
|
|
15
16
|
el: TimelineElement;
|
|
@@ -82,6 +83,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
82
83
|
"--clip-handle": theme.handleColor,
|
|
83
84
|
} as CSSProperties;
|
|
84
85
|
const isAudioClip = isAudioTimelineElement(el);
|
|
86
|
+
const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
|
|
87
|
+
const fade = useClipFadeDraft(el);
|
|
85
88
|
const clipClassName = [
|
|
86
89
|
"timeline-clip",
|
|
87
90
|
"absolute",
|
|
@@ -206,16 +209,21 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
206
209
|
{startLabel}-{endLabel}s
|
|
207
210
|
</span>
|
|
208
211
|
)}
|
|
209
|
-
{
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
<ClipFadesContext.Provider value={hasFades ? fade.shape : null}>
|
|
213
|
+
{children}
|
|
214
|
+
</ClipFadesContext.Provider>
|
|
215
|
+
{/* Fade handles for anything the mixer hears: audio clips and videos marked
|
|
216
|
+
data-has-audio. They write data-fade-in/out, the timeline half of the
|
|
217
|
+
inspector's Fade rows. */}
|
|
218
|
+
{hasFades && (
|
|
214
219
|
<TimelineClipFades
|
|
215
220
|
el={el}
|
|
216
221
|
pps={pps}
|
|
217
222
|
widthPx={widthPx}
|
|
218
223
|
showHandles={(isHovered || isSelected) && !isDragging}
|
|
224
|
+
focusable={isSelected}
|
|
225
|
+
hasWaveform={rendersWaveform(el)}
|
|
226
|
+
fade={fade}
|
|
219
227
|
/>
|
|
220
228
|
)}
|
|
221
229
|
</button>
|