@hyperframes/studio 0.7.94 → 0.7.96
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-D4mryRja.js → hyperframes-player-CogOkBTX.js} +1 -1
- package/dist/assets/{index-B0NWErIq.js → index-DfZlc9RM.js} +1 -1
- package/dist/assets/{index-BWh5m2-P.js → index-OMTw4iIl.js} +1 -1
- package/dist/assets/{index-CH_dyqrx.js → index-YO-rhwSW.js} +195 -195
- package/dist/assets/index-tBPidglp.css +1 -0
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +2648 -1838
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.selectionSync.test.tsx +109 -0
- package/src/components/EditorShell.tsx +31 -5
- package/src/components/TimelineToolbar.test.tsx +20 -1
- package/src/components/TimelineToolbar.tsx +29 -1
- package/src/hooks/useRenderClipContent.test.ts +77 -2
- package/src/hooks/useRenderClipContent.ts +78 -14
- package/src/hooks/useThumbnailLease.test.tsx +150 -0
- package/src/hooks/useThumbnailLease.ts +44 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
- package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
- package/src/player/components/AudioWaveform.test.tsx +53 -0
- package/src/player/components/AudioWaveform.tsx +137 -140
- package/src/player/components/CompositionThumbnail.test.ts +33 -10
- package/src/player/components/CompositionThumbnail.tsx +78 -61
- package/src/player/components/ImageThumbnail.test.tsx +26 -14
- package/src/player/components/ImageThumbnail.tsx +58 -101
- package/src/player/components/Timeline.tsx +20 -19
- package/src/player/components/TimelineGestureOverlay.tsx +3 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -2
- package/src/player/components/TimelineLanes.tsx +12 -12
- package/src/player/components/TimelineTypes.ts +6 -0
- package/src/player/components/VideoThumbnail.test.tsx +46 -100
- package/src/player/components/VideoThumbnail.tsx +74 -156
- package/src/player/components/thumbnailUtils.test.ts +35 -0
- package/src/player/components/thumbnailUtils.ts +86 -8
- package/src/player/components/timelineClipChildren.test.ts +31 -0
- package/src/player/components/timelineClipChildren.tsx +21 -2
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
- package/src/player/hooks/useTimelinePlayer.ts +14 -9
- package/src/player/lib/mediaProbe.test.ts +143 -0
- package/src/player/lib/mediaProbe.ts +117 -25
- package/src/player/lib/thumbnailPolicy.test.ts +14 -0
- package/src/player/lib/thumbnailPolicy.ts +24 -0
- package/src/player/lib/thumbnailScheduler.test.ts +388 -0
- package/src/player/lib/thumbnailScheduler.ts +483 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
- package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -0
- package/src/player/store/playerStore.ts +3 -1
- package/src/player/store/thumbnailSlice.ts +18 -0
- package/src/telemetry/canary.test.ts +25 -0
- package/src/telemetry/canary.ts +14 -4
- package/src/utils/frameCapture.test.ts +1 -1
- package/src/utils/frameCapture.ts +1 -0
- package/src/utils/projectRouting.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.ts +1 -0
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/dist/assets/index-D78KEjgB.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.96",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/core": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/studio-server": "0.7.
|
|
50
|
+
"@hyperframes/core": "0.7.96",
|
|
51
|
+
"@hyperframes/player": "0.7.96",
|
|
52
|
+
"@hyperframes/parsers": "0.7.96",
|
|
53
|
+
"@hyperframes/sdk": "0.7.96",
|
|
54
|
+
"@hyperframes/studio-server": "0.7.96"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"vite": "^6.4.2",
|
|
68
68
|
"vitest": "^3.2.4",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@hyperframes/producer": "0.7.
|
|
70
|
+
"@hyperframes/producer": "0.7.96"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"react": "19",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { EditorShell } from "./EditorShell";
|
|
7
|
+
|
|
8
|
+
const hookMocks = vi.hoisted(() => ({
|
|
9
|
+
useTimelineSelectionPreviewSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks);
|
|
13
|
+
vi.mock("../contexts/StudioContext", () => ({
|
|
14
|
+
useStudioPlaybackContext: () => ({
|
|
15
|
+
captionEditMode: false,
|
|
16
|
+
refreshKey: 0,
|
|
17
|
+
refreshPreviewDocumentVersion: vi.fn(),
|
|
18
|
+
timelineElements: [],
|
|
19
|
+
}),
|
|
20
|
+
useStudioShellContext: () => ({
|
|
21
|
+
projectId: "project-1",
|
|
22
|
+
activeCompPath: "index.html",
|
|
23
|
+
setActiveCompPath: vi.fn(),
|
|
24
|
+
handlePreviewIframeRef: vi.fn(),
|
|
25
|
+
showToast: vi.fn(),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("../contexts/DomEditContext", () => ({
|
|
29
|
+
useDomEditActionsContext: () => ({
|
|
30
|
+
handleTimelineElementSelect: vi.fn(),
|
|
31
|
+
buildDomSelectionForTimelineElement: vi.fn(),
|
|
32
|
+
applyDomSelection: vi.fn(),
|
|
33
|
+
applyMarqueeSelection: vi.fn(),
|
|
34
|
+
}),
|
|
35
|
+
useDomEditSelectionContext: () => ({
|
|
36
|
+
domEditSelection: null,
|
|
37
|
+
domEditGroupSelections: [],
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock("./nle/NLEContext", () => ({
|
|
41
|
+
NLEProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
42
|
+
useNLEContext: () => ({
|
|
43
|
+
compositionStack: [],
|
|
44
|
+
updateCompositionStack: vi.fn(),
|
|
45
|
+
containerRef: { current: null },
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
vi.mock("./nle/useTimelineEditCallbacks", () => ({
|
|
49
|
+
useTimelineEditCallbacks: () => ({}),
|
|
50
|
+
}));
|
|
51
|
+
vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
|
|
52
|
+
vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
|
|
53
|
+
vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
|
|
54
|
+
vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
|
|
55
|
+
vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null }));
|
|
56
|
+
|
|
57
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
document.body.innerHTML = "";
|
|
61
|
+
hookMocks.useTimelineSelectionPreviewSync.mockClear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("EditorShell timeline selection sync", () => {
|
|
65
|
+
it("keeps the timeline store mirrored into the preview selection", () => {
|
|
66
|
+
const host = document.createElement("div");
|
|
67
|
+
document.body.append(host);
|
|
68
|
+
const root = createRoot(host);
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
root.render(
|
|
72
|
+
<EditorShell
|
|
73
|
+
left={null}
|
|
74
|
+
right={null}
|
|
75
|
+
timelineToolbar={null}
|
|
76
|
+
renderClipContent={() => null}
|
|
77
|
+
handleTimelineElementDelete={vi.fn()}
|
|
78
|
+
handleTimelineAssetDrop={vi.fn()}
|
|
79
|
+
handleTimelineFileDrop={vi.fn()}
|
|
80
|
+
handleTimelineElementMove={vi.fn()}
|
|
81
|
+
handleTimelineElementsMove={vi.fn()}
|
|
82
|
+
handleTimelineElementResize={vi.fn()}
|
|
83
|
+
handleTimelineGroupResize={vi.fn()}
|
|
84
|
+
handleToggleTrackHidden={vi.fn()}
|
|
85
|
+
handleBlockedTimelineEdit={vi.fn()}
|
|
86
|
+
handleTimelineElementSplit={vi.fn()}
|
|
87
|
+
handleRazorSplit={vi.fn()}
|
|
88
|
+
handleRazorSplitAll={vi.fn()}
|
|
89
|
+
setCompIdToSrc={vi.fn()}
|
|
90
|
+
setCompositionLoading={vi.fn()}
|
|
91
|
+
shouldShowMotionPath={false}
|
|
92
|
+
shouldShowSelectedDomBounds={false}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce();
|
|
98
|
+
expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
activeCompPath: "index.html",
|
|
101
|
+
timelineElements: [],
|
|
102
|
+
domEditSelection: null,
|
|
103
|
+
domEditGroupSelections: [],
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
act(() => root.unmount());
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -10,11 +10,12 @@ import { NLEProvider, useNLEContext } from "./nle/NLEContext";
|
|
|
10
10
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
11
11
|
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
|
12
12
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
13
|
-
import { useDomEditActionsContext } from "../contexts/DomEditContext";
|
|
13
|
+
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
14
14
|
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
|
|
15
|
-
import type
|
|
15
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
16
16
|
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
|
17
17
|
import type { GestureRecordingState } from "./editor/GestureRecordControl";
|
|
18
|
+
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
|
|
18
19
|
|
|
19
20
|
type RenderClipContent = (
|
|
20
21
|
element: TimelineElement,
|
|
@@ -99,10 +100,35 @@ export function EditorShell({
|
|
|
99
100
|
blockPreview,
|
|
100
101
|
gestureOverlay,
|
|
101
102
|
}: EditorShellProps) {
|
|
102
|
-
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } =
|
|
103
|
+
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } =
|
|
103
104
|
useStudioShellContext();
|
|
104
|
-
const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } =
|
|
105
|
-
|
|
105
|
+
const { refreshKey, captionEditMode, refreshPreviewDocumentVersion, timelineElements } =
|
|
106
|
+
useStudioPlaybackContext();
|
|
107
|
+
const {
|
|
108
|
+
handleTimelineElementSelect,
|
|
109
|
+
buildDomSelectionForTimelineElement,
|
|
110
|
+
applyDomSelection,
|
|
111
|
+
applyMarqueeSelection,
|
|
112
|
+
} = useDomEditActionsContext();
|
|
113
|
+
const { domEditSelection, domEditGroupSelections } = useDomEditSelectionContext();
|
|
114
|
+
const selectedElementId = usePlayerStore((state) => state.selectedElementId);
|
|
115
|
+
const selectedElementIds = usePlayerStore((state) => state.selectedElementIds);
|
|
116
|
+
const reportTimelineSelectionNotFound = useCallback(() => {
|
|
117
|
+
showToast("The selected clip is not available in the preview yet.", "info");
|
|
118
|
+
}, [showToast]);
|
|
119
|
+
|
|
120
|
+
useTimelineSelectionPreviewSync({
|
|
121
|
+
selectedElementId,
|
|
122
|
+
selectedElementIds,
|
|
123
|
+
timelineElements,
|
|
124
|
+
domEditSelection,
|
|
125
|
+
domEditGroupSelections,
|
|
126
|
+
activeCompPath,
|
|
127
|
+
buildDomSelectionForTimelineElement,
|
|
128
|
+
applyDomSelection,
|
|
129
|
+
applyMarqueeSelection,
|
|
130
|
+
onSelectionNotFound: reportTimelineSelectionNotFound,
|
|
131
|
+
});
|
|
106
132
|
|
|
107
133
|
const timelineEditCallbacks = useTimelineEditCallbacks({
|
|
108
134
|
handleTimelineElementMove,
|
|
@@ -12,7 +12,7 @@ import { TimelineToolbar } from "./TimelineToolbar";
|
|
|
12
12
|
|
|
13
13
|
afterEach(() => {
|
|
14
14
|
document.body.innerHTML = "";
|
|
15
|
-
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
15
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, thumbnailMode: "adaptive" });
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
function renderToolbar(
|
|
@@ -58,6 +58,25 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
|
|
58
58
|
act(() => root.unmount());
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("TimelineToolbar — adaptive thumbnails", () => {
|
|
63
|
+
it("keeps a user-controlled hidden mode as the rollback path", () => {
|
|
64
|
+
const { host, root } = renderToolbar();
|
|
65
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
66
|
+
'button[aria-label="Hide thumbnails — labels only"]',
|
|
67
|
+
);
|
|
68
|
+
if (!button) throw new Error("thumbnail toggle not rendered");
|
|
69
|
+
|
|
70
|
+
act(() => button.click());
|
|
71
|
+
|
|
72
|
+
expect(usePlayerStore.getState().thumbnailMode).toBe("hidden");
|
|
73
|
+
expect(button.getAttribute("aria-label")).toBe(
|
|
74
|
+
"Show thumbnails — posters stay visible; richer previews appear on interaction",
|
|
75
|
+
);
|
|
76
|
+
act(() => root.unmount());
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
61
80
|
describe("TimelineToolbar — motion path endpoints", () => {
|
|
62
81
|
it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
|
|
63
82
|
usePlayerStore.setState({ currentTime: 10 });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
-
import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
|
|
2
|
+
import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
|
|
3
3
|
import {
|
|
4
4
|
useEnableKeyframes,
|
|
5
5
|
isPlayheadWithinTween,
|
|
@@ -111,6 +111,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
111
111
|
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
|
|
112
112
|
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
|
113
113
|
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
|
|
114
|
+
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
115
|
+
const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
|
|
116
|
+
const thumbnailsVisible = thumbnailMode === "adaptive";
|
|
114
117
|
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
|
|
115
118
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
116
119
|
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
|
@@ -403,6 +406,31 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
403
406
|
})()}
|
|
404
407
|
</div>
|
|
405
408
|
<div className="flex items-center gap-0.5">
|
|
409
|
+
<Tooltip
|
|
410
|
+
label={
|
|
411
|
+
thumbnailsVisible
|
|
412
|
+
? "Hide thumbnails — labels only"
|
|
413
|
+
: "Show thumbnails — posters stay visible; richer previews appear on interaction"
|
|
414
|
+
}
|
|
415
|
+
>
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
aria-label={
|
|
419
|
+
thumbnailsVisible
|
|
420
|
+
? "Hide thumbnails — labels only"
|
|
421
|
+
: "Show thumbnails — posters stay visible; richer previews appear on interaction"
|
|
422
|
+
}
|
|
423
|
+
aria-pressed={thumbnailsVisible}
|
|
424
|
+
onClick={() => setThumbnailMode(thumbnailsVisible ? "hidden" : "adaptive")}
|
|
425
|
+
className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
|
|
426
|
+
thumbnailsVisible
|
|
427
|
+
? "bg-studio-accent/10 text-studio-accent"
|
|
428
|
+
: "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
|
|
429
|
+
}`}
|
|
430
|
+
>
|
|
431
|
+
<Image size={16} aria-hidden="true" />
|
|
432
|
+
</button>
|
|
433
|
+
</Tooltip>
|
|
406
434
|
<Tooltip label="Fit timeline to width">
|
|
407
435
|
<button
|
|
408
436
|
type="button"
|
|
@@ -5,13 +5,15 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
7
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
8
|
-
import type {
|
|
8
|
+
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
9
|
+
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
|
9
10
|
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
|
10
11
|
import { useRenderClipContent } from "./useRenderClipContent";
|
|
11
12
|
|
|
12
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
14
|
|
|
14
15
|
afterEach(() => {
|
|
16
|
+
usePlayerStore.setState({ thumbnailMode: "hidden" });
|
|
15
17
|
document.body.innerHTML = "";
|
|
16
18
|
});
|
|
17
19
|
|
|
@@ -67,6 +69,7 @@ describe("useRenderClipContent", () => {
|
|
|
67
69
|
function renderClipContent(
|
|
68
70
|
el: TimelineElement,
|
|
69
71
|
activePreviewUrl: string | null = "/api/projects/my-project/preview",
|
|
72
|
+
context?: TimelineClipRenderContext,
|
|
70
73
|
): ReactNode {
|
|
71
74
|
const host = document.createElement("div");
|
|
72
75
|
document.body.append(host);
|
|
@@ -80,7 +83,7 @@ describe("useRenderClipContent", () => {
|
|
|
80
83
|
activePreviewUrl,
|
|
81
84
|
effectiveTimelineDuration: 12,
|
|
82
85
|
});
|
|
83
|
-
content = render(el, { clip: "#222", label: "#fff" });
|
|
86
|
+
content = render(el, { clip: "#222", label: "#fff" }, context);
|
|
84
87
|
return null;
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -105,7 +108,45 @@ describe("useRenderClipContent", () => {
|
|
|
105
108
|
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
|
|
106
109
|
});
|
|
107
110
|
|
|
111
|
+
it("routes root-relative iframe media back through the active project", () => {
|
|
112
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
113
|
+
const resolvedRootMedia = `${window.location.origin}/assets/clip.mp4`;
|
|
114
|
+
const video = renderClipContent(
|
|
115
|
+
{
|
|
116
|
+
id: "video",
|
|
117
|
+
tag: "video",
|
|
118
|
+
start: 0,
|
|
119
|
+
duration: 4,
|
|
120
|
+
track: 0,
|
|
121
|
+
src: resolvedRootMedia,
|
|
122
|
+
},
|
|
123
|
+
null,
|
|
124
|
+
);
|
|
125
|
+
const audio = renderClipContent({
|
|
126
|
+
id: "audio",
|
|
127
|
+
tag: "audio",
|
|
128
|
+
start: 0,
|
|
129
|
+
duration: 4,
|
|
130
|
+
track: 1,
|
|
131
|
+
src: resolvedRootMedia,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(isValidElement<{ videoSrc: string }>(video)).toBe(true);
|
|
135
|
+
expect(isValidElement<{ audioUrl: string; waveformUrl: string }>(audio)).toBe(true);
|
|
136
|
+
if (isValidElement<{ videoSrc: string }>(video)) {
|
|
137
|
+
expect(video.props.videoSrc).toBe("/api/projects/my-project/preview/assets/clip.mp4");
|
|
138
|
+
}
|
|
139
|
+
if (isValidElement<{ audioUrl: string; waveformUrl: string }>(audio)) {
|
|
140
|
+
expect(audio.props).toMatchObject({
|
|
141
|
+
audioUrl: "/api/projects/my-project/preview/assets/clip.mp4",
|
|
142
|
+
waveformUrl: "/api/projects/my-project/waveform/assets/clip.mp4",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
108
147
|
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
148
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
149
|
+
|
|
109
150
|
const cases: Array<{ content: ReactNode; type: unknown }> = [
|
|
110
151
|
{
|
|
111
152
|
content: renderClipContent({
|
|
@@ -166,4 +207,38 @@ describe("useRenderClipContent", () => {
|
|
|
166
207
|
}
|
|
167
208
|
}
|
|
168
209
|
});
|
|
210
|
+
|
|
211
|
+
it("forwards the viewport priority and interaction detail to media work", () => {
|
|
212
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 });
|
|
213
|
+
|
|
214
|
+
const content = renderClipContent(
|
|
215
|
+
{
|
|
216
|
+
id: "clip-video",
|
|
217
|
+
tag: "video",
|
|
218
|
+
start: 0,
|
|
219
|
+
duration: 4,
|
|
220
|
+
track: 0,
|
|
221
|
+
src: "assets/clip.mp4",
|
|
222
|
+
},
|
|
223
|
+
null,
|
|
224
|
+
{ priority: "interaction", rich: true },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(
|
|
228
|
+
isValidElement<{
|
|
229
|
+
projectId: string;
|
|
230
|
+
sessionEpoch: number;
|
|
231
|
+
priority: string;
|
|
232
|
+
rich: boolean;
|
|
233
|
+
}>(content),
|
|
234
|
+
).toBe(true);
|
|
235
|
+
if (isValidElement(content)) {
|
|
236
|
+
expect(content.props).toMatchObject({
|
|
237
|
+
projectId: "my-project",
|
|
238
|
+
sessionEpoch: 7,
|
|
239
|
+
priority: "interaction",
|
|
240
|
+
rich: true,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
169
244
|
});
|
|
@@ -2,9 +2,12 @@ import { useCallback, type ReactNode } from "react";
|
|
|
2
2
|
import { createElement } from "react";
|
|
3
3
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
4
4
|
import type { TimelineElement } from "../player";
|
|
5
|
+
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
5
6
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
6
7
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
7
8
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
9
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
|
+
import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
|
|
8
11
|
|
|
9
12
|
export function normalizeCompositionSrc(
|
|
10
13
|
compSrc: string,
|
|
@@ -24,12 +27,21 @@ export function normalizeCompositionSrc(
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/** Resolve a media src to its project-relative preview path, or null. */
|
|
27
|
-
function resolvePreviewRelative(
|
|
30
|
+
function resolvePreviewRelative(
|
|
31
|
+
src: string | undefined,
|
|
32
|
+
pid: string,
|
|
33
|
+
origin: string,
|
|
34
|
+
): string | null {
|
|
28
35
|
if (!src) return null;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(src, origin);
|
|
38
|
+
const base = new URL(`/api/projects/${pid}/preview/`, origin).pathname;
|
|
39
|
+
return parsed.pathname.startsWith(base)
|
|
40
|
+
? decodeURIComponent(parsed.pathname.slice(base.length))
|
|
41
|
+
: null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/**
|
|
@@ -51,15 +63,19 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
|
|
|
51
63
|
* Build the waveform element for an audio clip, windowing the rendered peaks to
|
|
52
64
|
* the trimmed source slice so the bars track the clip edges.
|
|
53
65
|
*/
|
|
54
|
-
function renderAudioClip(
|
|
55
|
-
|
|
66
|
+
function renderAudioClip(
|
|
67
|
+
el: TimelineElement,
|
|
68
|
+
pid: string,
|
|
69
|
+
sessionEpoch: number,
|
|
70
|
+
labelColor: string,
|
|
71
|
+
context: TimelineClipRenderContext,
|
|
72
|
+
): ReactNode {
|
|
73
|
+
const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
|
|
74
|
+
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
|
|
56
75
|
// Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
|
|
57
76
|
// what the assets panel loads — a raw segment 404s. resolvePreviewRelative
|
|
58
77
|
// returns the DECODED path, so it must be re-encoded here.
|
|
59
78
|
const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
|
|
60
|
-
const audioUrl = encodedRelative
|
|
61
|
-
? `/api/projects/${pid}/preview/${encodedRelative}`
|
|
62
|
-
: (el.src ?? "");
|
|
63
79
|
const waveformUrl = encodedRelative
|
|
64
80
|
? `/api/projects/${pid}/waveform/${encodedRelative}`
|
|
65
81
|
: undefined;
|
|
@@ -71,6 +87,9 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
|
|
|
71
87
|
labelColor,
|
|
72
88
|
trimStartFraction: start,
|
|
73
89
|
trimEndFraction: end,
|
|
90
|
+
projectId: pid,
|
|
91
|
+
sessionEpoch,
|
|
92
|
+
priority: context.priority,
|
|
74
93
|
});
|
|
75
94
|
}
|
|
76
95
|
|
|
@@ -87,13 +106,29 @@ export function useRenderClipContent({
|
|
|
87
106
|
activePreviewUrl,
|
|
88
107
|
effectiveTimelineDuration,
|
|
89
108
|
}: UseRenderClipContentOptions) {
|
|
109
|
+
// Self-sourced so the adaptive policy gates thumbnail generation without App plumbing.
|
|
110
|
+
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
111
|
+
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
|
|
112
|
+
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
90
113
|
return useCallback(
|
|
91
114
|
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
|
|
92
115
|
// fallow-ignore-next-line complexity
|
|
93
|
-
(
|
|
116
|
+
(
|
|
117
|
+
el: TimelineElement,
|
|
118
|
+
style: { clip: string; label: string },
|
|
119
|
+
context: TimelineClipRenderContext = { priority: "visible", rich: false },
|
|
120
|
+
): ReactNode => {
|
|
94
121
|
const pid = projectIdRef.current;
|
|
95
122
|
if (!pid) return null;
|
|
96
123
|
|
|
124
|
+
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
125
|
+
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
126
|
+
if (effectiveMode === "hidden") {
|
|
127
|
+
return el.tag === "audio"
|
|
128
|
+
? renderAudioClip(el, pid, sessionEpoch, style.label, context)
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
|
|
97
132
|
let compSrc = el.compositionSrc;
|
|
98
133
|
if (compSrc) {
|
|
99
134
|
compSrc = normalizeCompositionSrc(compSrc, pid, window.location.origin);
|
|
@@ -116,6 +151,10 @@ export function useRenderClipContent({
|
|
|
116
151
|
|
|
117
152
|
seekTime: 0,
|
|
118
153
|
duration: el.duration,
|
|
154
|
+
projectId: pid,
|
|
155
|
+
sessionEpoch,
|
|
156
|
+
priority: context.priority,
|
|
157
|
+
rich: context.rich,
|
|
119
158
|
});
|
|
120
159
|
}
|
|
121
160
|
|
|
@@ -123,7 +162,7 @@ export function useRenderClipContent({
|
|
|
123
162
|
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
|
|
124
163
|
// captured frame from the currently drilled composition preview.
|
|
125
164
|
if (el.tag === "audio") {
|
|
126
|
-
return renderAudioClip(el, pid, style.label);
|
|
165
|
+
return renderAudioClip(el, pid, sessionEpoch, style.label, context);
|
|
127
166
|
}
|
|
128
167
|
|
|
129
168
|
// When drilled into a composition, render all inner elements via
|
|
@@ -138,6 +177,10 @@ export function useRenderClipContent({
|
|
|
138
177
|
selectorIndex: el.selectorIndex,
|
|
139
178
|
seekTime: el.start,
|
|
140
179
|
duration: el.duration,
|
|
180
|
+
projectId: pid,
|
|
181
|
+
sessionEpoch,
|
|
182
|
+
priority: context.priority,
|
|
183
|
+
rich: context.rich,
|
|
141
184
|
});
|
|
142
185
|
}
|
|
143
186
|
|
|
@@ -148,7 +191,7 @@ export function useRenderClipContent({
|
|
|
148
191
|
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
|
149
192
|
|
|
150
193
|
if ((el.tag === "video" || el.tag === "img") && el.src) {
|
|
151
|
-
const mediaSrc = resolveMediaPreviewUrl(el.src, pid);
|
|
194
|
+
const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);
|
|
152
195
|
// Still images can't be decoded by VideoThumbnail's <video> extractor
|
|
153
196
|
// (the error event fires and the shimmer never resolves) — render the
|
|
154
197
|
// image itself as the strip.
|
|
@@ -157,6 +200,10 @@ export function useRenderClipContent({
|
|
|
157
200
|
imageSrc: mediaSrc,
|
|
158
201
|
label: "",
|
|
159
202
|
labelColor: style.label,
|
|
203
|
+
projectId: pid,
|
|
204
|
+
sessionEpoch,
|
|
205
|
+
priority: context.priority,
|
|
206
|
+
rich: context.rich,
|
|
160
207
|
});
|
|
161
208
|
}
|
|
162
209
|
return createElement(VideoThumbnail, {
|
|
@@ -164,6 +211,12 @@ export function useRenderClipContent({
|
|
|
164
211
|
label: "",
|
|
165
212
|
labelColor: style.label,
|
|
166
213
|
duration: el.duration,
|
|
214
|
+
sourceStart: el.playbackStart,
|
|
215
|
+
sourceRangeDuration: el.duration * (el.playbackRate ?? 1),
|
|
216
|
+
projectId: pid,
|
|
217
|
+
sessionEpoch,
|
|
218
|
+
priority: context.priority,
|
|
219
|
+
rich: context.rich,
|
|
167
220
|
});
|
|
168
221
|
}
|
|
169
222
|
|
|
@@ -177,11 +230,22 @@ export function useRenderClipContent({
|
|
|
177
230
|
selectorIndex: el.selectorIndex,
|
|
178
231
|
seekTime: el.start,
|
|
179
232
|
duration: el.duration,
|
|
233
|
+
projectId: pid,
|
|
234
|
+
sessionEpoch,
|
|
235
|
+
priority: context.priority,
|
|
236
|
+
rich: context.rich,
|
|
180
237
|
});
|
|
181
238
|
}
|
|
182
239
|
|
|
183
240
|
return null;
|
|
184
241
|
},
|
|
185
|
-
[
|
|
242
|
+
[
|
|
243
|
+
projectIdRef,
|
|
244
|
+
compIdToSrc,
|
|
245
|
+
activePreviewUrl,
|
|
246
|
+
effectiveTimelineDuration,
|
|
247
|
+
effectiveMode,
|
|
248
|
+
sessionEpoch,
|
|
249
|
+
],
|
|
186
250
|
);
|
|
187
251
|
}
|