@hyperframes/studio 0.5.0-alpha.9 → 0.5.0
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-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1438
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2466
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +5 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
- package/src/player/hooks/useTimelinePlayer.ts +206 -93
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -445
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
CaretRight,
|
|
54
54
|
ClipboardText,
|
|
55
55
|
ArrowCounterClockwise,
|
|
56
|
+
Camera as PhCamera,
|
|
56
57
|
Gear,
|
|
57
58
|
} from "@phosphor-icons/react";
|
|
58
59
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
@@ -127,4 +128,5 @@ export const ChevronDown = makeIcon(CaretDown);
|
|
|
127
128
|
export const ChevronRight = makeIcon(CaretRight);
|
|
128
129
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
129
130
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
131
|
+
export const Camera = makeIcon(PhCamera);
|
|
130
132
|
export const Settings = makeIcon(Gear);
|
|
@@ -7,7 +7,6 @@ interface CompositionThumbnailProps {
|
|
|
7
7
|
labelColor: string;
|
|
8
8
|
accentColor?: string;
|
|
9
9
|
selector?: string;
|
|
10
|
-
selectorIndex?: number;
|
|
11
10
|
seekTime?: number;
|
|
12
11
|
duration?: number;
|
|
13
12
|
width?: number;
|
|
@@ -17,44 +16,12 @@ interface CompositionThumbnailProps {
|
|
|
17
16
|
const CLIP_HEIGHT = 66;
|
|
18
17
|
const THUMBNAIL_URL_VERSION = "v2";
|
|
19
18
|
|
|
20
|
-
export function buildCompositionThumbnailUrl({
|
|
21
|
-
previewUrl,
|
|
22
|
-
seekTime = 2,
|
|
23
|
-
duration = 5,
|
|
24
|
-
selector,
|
|
25
|
-
selectorIndex,
|
|
26
|
-
origin,
|
|
27
|
-
}: {
|
|
28
|
-
previewUrl: string;
|
|
29
|
-
seekTime?: number;
|
|
30
|
-
duration?: number;
|
|
31
|
-
selector?: string;
|
|
32
|
-
selectorIndex?: number;
|
|
33
|
-
origin: string;
|
|
34
|
-
}): string {
|
|
35
|
-
const thumbnailBase = previewUrl
|
|
36
|
-
.replace("/preview/comp/", "/thumbnail/")
|
|
37
|
-
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
38
|
-
const midTime = seekTime + duration / 2;
|
|
39
|
-
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
40
|
-
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
41
|
-
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
42
|
-
if (selector) {
|
|
43
|
-
thumbnailUrl.searchParams.set("selector", selector);
|
|
44
|
-
if (selectorIndex != null && selectorIndex > 0) {
|
|
45
|
-
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return thumbnailUrl.toString();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
19
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
52
20
|
previewUrl,
|
|
53
21
|
label,
|
|
54
22
|
labelColor,
|
|
55
23
|
accentColor = "#6B7280",
|
|
56
24
|
selector,
|
|
57
|
-
selectorIndex,
|
|
58
25
|
seekTime = 2,
|
|
59
26
|
duration = 5,
|
|
60
27
|
}: CompositionThumbnailProps) {
|
|
@@ -81,14 +48,15 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
81
48
|
roRef.current?.disconnect();
|
|
82
49
|
});
|
|
83
50
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
51
|
+
const thumbnailBase = previewUrl
|
|
52
|
+
.replace("/preview/comp/", "/thumbnail/")
|
|
53
|
+
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
54
|
+
const midTime = seekTime + duration / 2;
|
|
55
|
+
const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
|
|
56
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
57
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
58
|
+
if (selector) thumbnailUrl.searchParams.set("selector", selector);
|
|
59
|
+
const url = thumbnailUrl.toString();
|
|
92
60
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
93
61
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
94
62
|
|
|
@@ -98,7 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
98
66
|
src={url}
|
|
99
67
|
alt=""
|
|
100
68
|
draggable={false}
|
|
101
|
-
loading="
|
|
69
|
+
loading="lazy"
|
|
102
70
|
onLoad={(e) => {
|
|
103
71
|
const img = e.currentTarget;
|
|
104
72
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
@@ -3,7 +3,6 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
-
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
7
6
|
|
|
8
7
|
interface EditPopoverProps {
|
|
9
8
|
rangeStart: number;
|
|
@@ -63,8 +62,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
63
62
|
}, [start, end, elementsInRange, prompt]);
|
|
64
63
|
|
|
65
64
|
const handleCopy = useCallback(async () => {
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
try {
|
|
66
|
+
await navigator.clipboard.writeText(buildClipboardText());
|
|
67
|
+
} catch {
|
|
68
|
+
const ta = document.createElement("textarea");
|
|
69
|
+
ta.value = buildClipboardText();
|
|
70
|
+
document.body.appendChild(ta);
|
|
71
|
+
ta.select();
|
|
72
|
+
document.execCommand("copy");
|
|
73
|
+
document.body.removeChild(ta);
|
|
74
|
+
}
|
|
68
75
|
setCopiedAgentPrompt(true);
|
|
69
76
|
setTimeout(() => {
|
|
70
77
|
setCopiedAgentPrompt(false);
|
|
@@ -75,8 +82,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
75
82
|
const handleCopyPrompt = useCallback(async () => {
|
|
76
83
|
const promptText = buildPromptCopyText(prompt);
|
|
77
84
|
if (!promptText) return;
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(promptText);
|
|
87
|
+
} catch {
|
|
88
|
+
const ta = document.createElement("textarea");
|
|
89
|
+
ta.value = promptText;
|
|
90
|
+
document.body.appendChild(ta);
|
|
91
|
+
ta.select();
|
|
92
|
+
document.execCommand("copy");
|
|
93
|
+
document.body.removeChild(ta);
|
|
94
|
+
}
|
|
80
95
|
setCopiedPromptOnly(true);
|
|
81
96
|
setTimeout(() => {
|
|
82
97
|
setCopiedPromptOnly(false);
|
|
@@ -1,37 +1,43 @@
|
|
|
1
|
-
import { forwardRef, useRef, useState } from "react";
|
|
1
|
+
import { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
|
|
2
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import { HyperframesLoader } from "../../components/ui";
|
|
5
|
+
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
|
6
|
+
// at module load, which throws under SSR. Defer the import to the mount effect
|
|
7
|
+
// so it only runs in the browser.
|
|
3
8
|
|
|
4
9
|
interface PlayerProps {
|
|
5
10
|
projectId?: string;
|
|
6
11
|
directUrl?: string;
|
|
7
12
|
onLoad: () => void;
|
|
8
13
|
portrait?: boolean;
|
|
9
|
-
style?: React.CSSProperties;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
interface HyperframesPlayerElement extends HTMLElement {
|
|
13
17
|
iframeElement: HTMLIFrameElement;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
if (!root) return;
|
|
19
|
-
|
|
20
|
-
const container = root.querySelector<HTMLElement>(".hfp-container");
|
|
21
|
-
const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
|
|
22
|
-
|
|
23
|
-
container?.style.setProperty("pointer-events", "auto");
|
|
24
|
-
iframe?.style.setProperty("pointer-events", "auto");
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
function
|
|
28
|
-
if (
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
return
|
|
24
|
+
function getShaderTransitionLoading(event: Event): boolean | null {
|
|
25
|
+
if (!(event instanceof CustomEvent)) return null;
|
|
26
|
+
const detail: unknown = event.detail;
|
|
27
|
+
if (!isRecord(detail)) return null;
|
|
28
|
+
const state = detail.state;
|
|
29
|
+
if (!isRecord(state)) return null;
|
|
30
|
+
return state.loading === true && state.ready !== true;
|
|
33
31
|
}
|
|
34
32
|
|
|
33
|
+
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
|
34
|
+
// to play through without buffering, and every registered Lottie animation has
|
|
35
|
+
// finished loading.
|
|
36
|
+
//
|
|
37
|
+
// Returns whichever value was returned last on cross-origin / transient DOM
|
|
38
|
+
// races so a brief access failure (e.g. an iframe that just swapped src)
|
|
39
|
+
// doesn't flicker the overlay state — we keep showing whatever was most
|
|
40
|
+
// recently true.
|
|
35
41
|
function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
|
|
36
42
|
try {
|
|
37
43
|
const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
|
|
@@ -47,7 +53,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
|
|
47
53
|
const lotties = win.__hfLottie;
|
|
48
54
|
if (lotties?.length) {
|
|
49
55
|
for (const anim of lotties) {
|
|
50
|
-
if (!
|
|
56
|
+
if (!isLottieAnimationLoaded(anim)) return true;
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -57,11 +63,24 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Renders a composition preview using the <hyperframes-player> web component.
|
|
68
|
+
*
|
|
69
|
+
* The web component handles iframe scaling, dimension detection, and
|
|
70
|
+
* ResizeObserver internally. This wrapper bridges its inner iframe to the
|
|
71
|
+
* forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
|
|
72
|
+
* timeline probing, and DOM inspection.
|
|
73
|
+
*/
|
|
60
74
|
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
61
|
-
({ projectId, directUrl, onLoad, portrait
|
|
75
|
+
({ projectId, directUrl, onLoad, portrait }, ref) => {
|
|
62
76
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
const loadCountRef = useRef(0);
|
|
63
78
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
79
|
+
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
64
80
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
81
|
+
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
82
|
+
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
83
|
+
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
65
84
|
|
|
66
85
|
useMountEffect(() => {
|
|
67
86
|
const container = containerRef.current;
|
|
@@ -70,11 +89,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
70
89
|
let canceled = false;
|
|
71
90
|
let cleanup: (() => void) | undefined;
|
|
72
91
|
|
|
92
|
+
// Dynamic import registers the custom element in the browser only.
|
|
73
93
|
import("@hyperframes/player").then(() => {
|
|
74
94
|
if (canceled) return;
|
|
75
95
|
|
|
96
|
+
// Create the web component imperatively to avoid JSX custom-element typing.
|
|
76
97
|
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
|
77
98
|
const src = directUrl || `/api/projects/${projectId}/preview`;
|
|
99
|
+
player.setAttribute("shader-capture-scale", "1");
|
|
100
|
+
player.setAttribute("shader-loading", "player");
|
|
78
101
|
player.setAttribute("src", src);
|
|
79
102
|
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
80
103
|
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
|
@@ -82,8 +105,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
82
105
|
player.style.height = "100%";
|
|
83
106
|
player.style.display = "block";
|
|
84
107
|
container.appendChild(player);
|
|
85
|
-
enableInteractiveIframe(player);
|
|
86
108
|
|
|
109
|
+
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
87
110
|
const iframe = player.iframeElement;
|
|
88
111
|
if (typeof ref === "function") {
|
|
89
112
|
ref(iframe);
|
|
@@ -91,12 +114,42 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
91
114
|
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
92
115
|
}
|
|
93
116
|
|
|
117
|
+
// Prevent the web component's built-in click-to-toggle behavior.
|
|
118
|
+
// The studio manages playback exclusively via useTimelinePlayer.
|
|
94
119
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
|
95
120
|
player.addEventListener("click", preventToggle, { capture: true });
|
|
96
121
|
|
|
122
|
+
const handleShaderTransitionState = (event: Event) => {
|
|
123
|
+
const loading = getShaderTransitionLoading(event);
|
|
124
|
+
if (loading !== null) setShaderTransitionLoading(loading);
|
|
125
|
+
};
|
|
126
|
+
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
127
|
+
|
|
128
|
+
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
97
129
|
const handleLoad = () => {
|
|
130
|
+
loadCountRef.current++;
|
|
131
|
+
setShaderTransitionLoading(false);
|
|
132
|
+
// Reveal animation on reload (hot-reload, composition switch)
|
|
133
|
+
if (loadCountRef.current > 1) {
|
|
134
|
+
container.classList.remove("preview-revealing");
|
|
135
|
+
void container.offsetWidth;
|
|
136
|
+
container.classList.add("preview-revealing");
|
|
137
|
+
const onEnd = () => container.classList.remove("preview-revealing");
|
|
138
|
+
container.addEventListener("animationend", onEnd, { once: true });
|
|
139
|
+
}
|
|
98
140
|
onLoad();
|
|
99
141
|
|
|
142
|
+
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
|
|
143
|
+
// asset is ready. Without this users can click play before audio has
|
|
144
|
+
// buffered — the runtime is resilient (queued play() resolves once
|
|
145
|
+
// data arrives), but the overlay communicates why the first frame
|
|
146
|
+
// or first audio beat may lag.
|
|
147
|
+
//
|
|
148
|
+
// Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
|
|
149
|
+
// trips we hide the overlay so the UI doesn't appear stuck forever,
|
|
150
|
+
// but we log a debug warning so the case is diagnosable — a long
|
|
151
|
+
// cold video or a broken asset can legitimately exceed 10 s on a
|
|
152
|
+
// slow network.
|
|
100
153
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
101
154
|
let lastUnloaded = hasUnloadedAssets(iframe, false);
|
|
102
155
|
if (lastUnloaded) {
|
|
@@ -109,6 +162,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
109
162
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
110
163
|
assetPollRef.current = null;
|
|
111
164
|
setAssetsLoading(false);
|
|
165
|
+
if (lastUnloaded) {
|
|
166
|
+
console.debug(
|
|
167
|
+
"[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
112
170
|
}
|
|
113
171
|
}, 100);
|
|
114
172
|
} else {
|
|
@@ -120,9 +178,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
120
178
|
cleanup = () => {
|
|
121
179
|
iframe.removeEventListener("load", handleLoad);
|
|
122
180
|
player.removeEventListener("click", preventToggle, { capture: true });
|
|
181
|
+
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
123
182
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
124
183
|
assetPollRef.current = null;
|
|
125
184
|
container.removeChild(player);
|
|
185
|
+
// Clear the forwarded ref
|
|
126
186
|
if (typeof ref === "function") {
|
|
127
187
|
ref(null);
|
|
128
188
|
} else if (ref) {
|
|
@@ -137,16 +197,57 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
137
197
|
};
|
|
138
198
|
});
|
|
139
199
|
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (assetFadeRef.current) {
|
|
202
|
+
clearTimeout(assetFadeRef.current);
|
|
203
|
+
assetFadeRef.current = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (assetsLoading) {
|
|
207
|
+
setAssetOverlayVisible(true);
|
|
208
|
+
setAssetOverlayFading(false);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setAssetOverlayFading(true);
|
|
213
|
+
assetFadeRef.current = setTimeout(() => {
|
|
214
|
+
setAssetOverlayVisible(false);
|
|
215
|
+
setAssetOverlayFading(false);
|
|
216
|
+
assetFadeRef.current = null;
|
|
217
|
+
}, 240);
|
|
218
|
+
|
|
219
|
+
return () => {
|
|
220
|
+
if (assetFadeRef.current) {
|
|
221
|
+
clearTimeout(assetFadeRef.current);
|
|
222
|
+
assetFadeRef.current = null;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}, [assetsLoading]);
|
|
226
|
+
|
|
227
|
+
const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
|
|
228
|
+
|
|
140
229
|
return (
|
|
141
|
-
<div
|
|
142
|
-
className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
|
|
143
|
-
style={style}
|
|
144
|
-
>
|
|
230
|
+
<div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
|
|
145
231
|
<div ref={containerRef} className="w-full h-full" />
|
|
146
|
-
{
|
|
147
|
-
<div
|
|
148
|
-
|
|
149
|
-
|
|
232
|
+
{showAssetOverlay && (
|
|
233
|
+
<div
|
|
234
|
+
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
|
|
235
|
+
data-hyperframes-ignore=""
|
|
236
|
+
draggable={false}
|
|
237
|
+
style={{
|
|
238
|
+
opacity: assetOverlayFading ? 0 : 1,
|
|
239
|
+
pointerEvents: assetOverlayFading ? "none" : "auto",
|
|
240
|
+
transition: "opacity 240ms ease-out",
|
|
241
|
+
}}
|
|
242
|
+
onDragStart={(event) => event.preventDefault()}
|
|
243
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
244
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
245
|
+
>
|
|
246
|
+
<HyperframesLoader
|
|
247
|
+
title="Preparing preview assets"
|
|
248
|
+
detail="Waiting for media and motion assets before playback starts."
|
|
249
|
+
size={56}
|
|
250
|
+
/>
|
|
150
251
|
</div>
|
|
151
252
|
)}
|
|
152
253
|
</div>
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import {
|
|
4
|
-
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
5
|
-
getTimelineToggleTitle,
|
|
6
|
-
} from "../../utils/timelineDiscovery";
|
|
7
|
-
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
|
3
|
+
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
|
|
8
4
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
9
5
|
|
|
10
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
|
|
30
26
|
interface PlayerControlsProps {
|
|
31
27
|
onTogglePlay: () => void;
|
|
32
28
|
onSeek: (time: number) => void;
|
|
33
|
-
timelineVisible?: boolean;
|
|
34
|
-
onToggleTimeline?: () => void;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
export const PlayerControls = memo(function PlayerControls({
|
|
38
32
|
onTogglePlay,
|
|
39
33
|
onSeek,
|
|
40
|
-
timelineVisible,
|
|
41
|
-
onToggleTimeline,
|
|
42
34
|
}: PlayerControlsProps) {
|
|
43
35
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
44
36
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
@@ -216,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
216
208
|
const step = e.shiftKey ? 10 : 1;
|
|
217
209
|
if (e.key === "ArrowLeft") {
|
|
218
210
|
e.preventDefault();
|
|
219
|
-
onSeek(
|
|
211
|
+
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
220
212
|
} else if (e.key === "ArrowRight") {
|
|
221
213
|
e.preventDefault();
|
|
222
|
-
onSeek(Math.min(duration, currentTimeRef.current
|
|
214
|
+
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
|
223
215
|
}
|
|
224
216
|
},
|
|
225
217
|
[timelineReady, duration, onSeek],
|
|
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
437
429
|
</span>
|
|
438
430
|
))}
|
|
439
431
|
</div>
|
|
440
|
-
|
|
441
|
-
{/* Timeline toggle */}
|
|
442
|
-
{onToggleTimeline !== undefined && (
|
|
443
|
-
<button
|
|
444
|
-
type="button"
|
|
445
|
-
onClick={onToggleTimeline}
|
|
446
|
-
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
|
|
447
|
-
timelineVisible
|
|
448
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
449
|
-
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
450
|
-
}`}
|
|
451
|
-
title={getTimelineToggleTitle(Boolean(timelineVisible))}
|
|
452
|
-
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
453
|
-
>
|
|
454
|
-
<svg
|
|
455
|
-
width="13"
|
|
456
|
-
height="13"
|
|
457
|
-
viewBox="0 0 24 24"
|
|
458
|
-
fill="none"
|
|
459
|
-
stroke="currentColor"
|
|
460
|
-
strokeWidth="2"
|
|
461
|
-
strokeLinecap="round"
|
|
462
|
-
>
|
|
463
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
464
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
465
|
-
<line x1="3" y1="5" x2="21" y2="5" />
|
|
466
|
-
</svg>
|
|
467
|
-
<span>Timeline</span>
|
|
468
|
-
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
|
469
|
-
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
470
|
-
</span>
|
|
471
|
-
</button>
|
|
472
|
-
)}
|
|
473
432
|
</div>
|
|
474
433
|
);
|
|
475
434
|
});
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
getTimelinePlayheadLeft,
|
|
9
9
|
getTimelineScrollLeftForZoomAnchor,
|
|
10
10
|
getTimelineScrollLeftForZoomTransition,
|
|
11
|
-
shouldShowTimelineShortcutHint,
|
|
12
11
|
shouldHandleTimelineDeleteKey,
|
|
13
12
|
shouldAutoScrollTimeline,
|
|
14
13
|
} from "./Timeline";
|
|
@@ -238,17 +237,6 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
238
237
|
});
|
|
239
238
|
});
|
|
240
239
|
|
|
241
|
-
describe("shouldShowTimelineShortcutHint", () => {
|
|
242
|
-
it("shows the hint when the timeline does not vertically overflow", () => {
|
|
243
|
-
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
|
|
244
|
-
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("hides the hint when timeline tracks need vertical scrolling", () => {
|
|
248
|
-
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
240
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
253
241
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
254
242
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
@@ -35,7 +35,7 @@ const TRACK_H = 72;
|
|
|
35
35
|
const RULER_H = 24;
|
|
36
36
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
37
|
const CLIP_HANDLE_W = 18;
|
|
38
|
-
const TIMELINE_SCROLL_BUFFER =
|
|
38
|
+
const TIMELINE_SCROLL_BUFFER = 24;
|
|
39
39
|
|
|
40
40
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
41
|
icon: ReactNode;
|
|
@@ -216,14 +216,6 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
216
216
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
export function shouldShowTimelineShortcutHint(
|
|
220
|
-
scrollHeight: number,
|
|
221
|
-
clientHeight: number,
|
|
222
|
-
): boolean {
|
|
223
|
-
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
224
|
-
return scrollHeight - clientHeight <= 1;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
219
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
228
220
|
key: string;
|
|
229
221
|
metaKey?: boolean;
|
|
@@ -287,6 +279,7 @@ export function resolveTimelineAssetDrop(
|
|
|
287
279
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
288
280
|
};
|
|
289
281
|
}
|
|
282
|
+
|
|
290
283
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
291
284
|
interface TimelineProps {
|
|
292
285
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -434,51 +427,30 @@ export const Timeline = memo(function Timeline({
|
|
|
434
427
|
onDeleteElementRef.current = onDeleteElement;
|
|
435
428
|
const suppressClickRef = useRef(false);
|
|
436
429
|
const [showPopover, setShowPopover] = useState(false);
|
|
437
|
-
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
438
430
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
439
431
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
440
|
-
const shortcutHintRafRef = useRef(0);
|
|
441
|
-
const syncShortcutHintVisibility = useCallback(() => {
|
|
442
|
-
const scroll = scrollRef.current;
|
|
443
|
-
setShowShortcutHint(
|
|
444
|
-
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
445
|
-
);
|
|
446
|
-
}, []);
|
|
447
|
-
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
448
|
-
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
449
|
-
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
450
|
-
shortcutHintRafRef.current = 0;
|
|
451
|
-
syncShortcutHintVisibility();
|
|
452
|
-
});
|
|
453
|
-
}, [syncShortcutHintVisibility]);
|
|
454
432
|
|
|
455
433
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
456
434
|
// useMountEffect can't work here because the component returns null on first
|
|
457
435
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
458
436
|
// effect fires and the ResizeObserver is never created.
|
|
459
|
-
const setContainerRef = useCallback(
|
|
460
|
-
(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
});
|
|
473
|
-
roRef.current.observe(el);
|
|
474
|
-
},
|
|
475
|
-
[scheduleShortcutHintVisibilitySync],
|
|
476
|
-
);
|
|
437
|
+
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
438
|
+
if (roRef.current) {
|
|
439
|
+
roRef.current.disconnect();
|
|
440
|
+
roRef.current = null;
|
|
441
|
+
}
|
|
442
|
+
containerRef.current = el;
|
|
443
|
+
if (!el) return;
|
|
444
|
+
setViewportWidth(el.clientWidth);
|
|
445
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
446
|
+
setViewportWidth(entry.contentRect.width);
|
|
447
|
+
});
|
|
448
|
+
roRef.current.observe(el);
|
|
449
|
+
}, []);
|
|
477
450
|
|
|
478
451
|
// Clean up ResizeObserver on unmount
|
|
479
452
|
useMountEffect(() => () => {
|
|
480
453
|
roRef.current?.disconnect();
|
|
481
|
-
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
482
454
|
});
|
|
483
455
|
|
|
484
456
|
// Effective duration: max of store duration and the furthest element end.
|
|
@@ -523,7 +495,6 @@ export const Timeline = memo(function Timeline({
|
|
|
523
495
|
}
|
|
524
496
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
525
497
|
}, [draggedClip, trackOrder]);
|
|
526
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
527
498
|
const selectedElement = useMemo(
|
|
528
499
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
529
500
|
[elements, selectedElementId],
|
|
@@ -573,6 +544,7 @@ export const Timeline = memo(function Timeline({
|
|
|
573
544
|
);
|
|
574
545
|
previousZoomModeRef.current = zoomMode;
|
|
575
546
|
}, [zoomMode]);
|
|
547
|
+
|
|
576
548
|
useMountEffect(() => {
|
|
577
549
|
const unsub = liveTime.subscribe((t) => {
|
|
578
550
|
const dur = durationRef.current;
|
|
@@ -1040,12 +1012,12 @@ export const Timeline = memo(function Timeline({
|
|
|
1040
1012
|
);
|
|
1041
1013
|
const majorTickInterval =
|
|
1042
1014
|
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1043
|
-
useEffect(() => {
|
|
1044
|
-
syncShortcutHintVisibility();
|
|
1045
|
-
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
1046
1015
|
const getPreviewElement = useCallback(
|
|
1047
1016
|
(element: TimelineElement): TimelineElement => {
|
|
1048
|
-
if (
|
|
1017
|
+
if (
|
|
1018
|
+
resizingClip &&
|
|
1019
|
+
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
|
|
1020
|
+
) {
|
|
1049
1021
|
return {
|
|
1050
1022
|
...element,
|
|
1051
1023
|
start: resizingClip.previewStart,
|
|
@@ -1267,12 +1239,13 @@ export const Timeline = memo(function Timeline({
|
|
|
1267
1239
|
);
|
|
1268
1240
|
}
|
|
1269
1241
|
|
|
1242
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1270
1243
|
const draggedElement = draggedClip?.element ?? null;
|
|
1271
1244
|
const activeDraggedElement =
|
|
1272
1245
|
draggedClip?.started === true && draggedElement
|
|
1273
1246
|
? getRenderedTimelineElement({
|
|
1274
1247
|
element: draggedElement,
|
|
1275
|
-
draggedElementId: draggedElement.id,
|
|
1248
|
+
draggedElementId: draggedElement.key ?? draggedElement.id,
|
|
1276
1249
|
previewStart: draggedClip.previewStart,
|
|
1277
1250
|
previewTrack: draggedClip.previewTrack,
|
|
1278
1251
|
})
|
|
@@ -1340,7 +1313,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1340
1313
|
<div
|
|
1341
1314
|
ref={setContainerRef}
|
|
1342
1315
|
aria-label="Timeline"
|
|
1343
|
-
className={`
|
|
1316
|
+
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1344
1317
|
style={{
|
|
1345
1318
|
touchAction: "pan-x pan-y",
|
|
1346
1319
|
background: theme.shellBackground,
|
|
@@ -1679,8 +1652,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1679
1652
|
</div>
|
|
1680
1653
|
</div>
|
|
1681
1654
|
|
|
1682
|
-
{/* Keyboard shortcut hint */}
|
|
1683
|
-
{
|
|
1655
|
+
{/* Keyboard shortcut hint — always visible */}
|
|
1656
|
+
{!showPopover && !rangeSelection && (
|
|
1684
1657
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1685
1658
|
<div
|
|
1686
1659
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|