@hyperframes/studio 0.8.105 → 0.8.106
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-dl6K6ABp.js} +1 -1
- package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
- package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
- package/dist/chunk-ICTZJPQQ.js.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.html +11 -2
- package/dist/index.js +6601 -6022
- 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/captions/keyboard.test.ts +6 -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/ThemeToggle.tsx +99 -0
- package/src/components/editor/DomEditOverlay.tsx +37 -5
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
- package/src/components/editor/manualEditsDom.ts +33 -0
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -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/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +31 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- 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/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.test.ts +32 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +7 -43
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.ts +16 -2
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +2 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/index.ts +2 -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/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/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -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/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/studioPendingEdits.ts +21 -0
- 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 +8 -1
- package/src/utils/typingTarget.ts +5 -0
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -22,7 +22,7 @@ function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
|
|
|
22
22
|
// Header actions stay native Tab stops because they have no row-level shortcut.
|
|
23
23
|
// The nearest interactive ancestor wins so their events never masquerade as row events.
|
|
24
24
|
const target = event.target.closest<HTMLElement>(
|
|
25
|
-
"button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]",
|
|
25
|
+
"button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
|
|
26
26
|
);
|
|
27
27
|
return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
|
|
28
28
|
}
|
|
@@ -118,3 +118,30 @@ export function attachIframeAdapter(
|
|
|
118
118
|
attachIframeWindow(api, win);
|
|
119
119
|
return adapter;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
export function makePreview(body: string, query = ""): HTMLIFrameElement {
|
|
123
|
+
const iframe = makeFakeIframe(makeAdapterWindow().win);
|
|
124
|
+
iframe.src = `http://localhost/api/projects/demo/preview${query}`;
|
|
125
|
+
iframe.contentDocument!.body.innerHTML = body;
|
|
126
|
+
return iframe;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function mountPlayerWithPreview(live: HTMLIFrameElement) {
|
|
130
|
+
const harness = renderTimelinePlayerHarness();
|
|
131
|
+
act(() => {
|
|
132
|
+
harness.getApi().iframeRef.current = live;
|
|
133
|
+
harness.getApi().onIframeLoad();
|
|
134
|
+
});
|
|
135
|
+
return harness;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function paintShadow(getApi: () => TimelinePlayerApi, shadow: HTMLIFrameElement) {
|
|
139
|
+
const slot = getApi().previewSlots.find((s) => s.role === "shadow");
|
|
140
|
+
if (!slot) throw new Error("no shadow reload is pending");
|
|
141
|
+
act(() => getApi().setShadowIframeNode(shadow));
|
|
142
|
+
await act(async () => {
|
|
143
|
+
getApi().onShadowIframeLoad(slot.gen);
|
|
144
|
+
getApi().onShadowReadyChange(slot.gen, true);
|
|
145
|
+
});
|
|
146
|
+
return slot.gen;
|
|
147
|
+
}
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { useCallback, useRef, useState } from "react";
|
|
5
5
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
6
|
+
import {
|
|
7
|
+
afterStudioManualEditGestures,
|
|
8
|
+
isStudioManualEditGestureLiveIn,
|
|
9
|
+
studioManualEditSavesIn,
|
|
10
|
+
} from "../../components/editor/manualEditsDom";
|
|
6
11
|
import { logReload } from "../../utils/reloadDebug";
|
|
7
12
|
import {
|
|
8
13
|
useTimelineSyncCallbacks,
|
|
@@ -14,6 +19,8 @@ import {
|
|
|
14
19
|
} from "./useTimelineSyncCallbacks";
|
|
15
20
|
import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
21
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
22
|
+
import { announcePreviewPromoted } from "../sceneSwap";
|
|
23
|
+
import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
|
|
17
24
|
import { usePlayerStore } from "../store/playerStore";
|
|
18
25
|
|
|
19
26
|
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
@@ -78,12 +85,18 @@ export function useShadowPreviewReload({
|
|
|
78
85
|
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
79
86
|
const budgetGenRef = useRef<number | null>(null);
|
|
80
87
|
const cancelPendingLoadRef = useRef<() => void>(() => {});
|
|
88
|
+
const stopHoldRef = useRef<() => void>(() => {});
|
|
89
|
+
const shadowUrlRef = useRef("");
|
|
90
|
+
const savesAtRequestRef = useRef(0);
|
|
91
|
+
const beginShadowReloadRef = useRef<(url: string) => void>(() => {});
|
|
92
|
+
const promoteWhenReadyRef = useRef<(gen: number) => void>(() => {});
|
|
81
93
|
const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
|
|
82
94
|
|
|
83
95
|
const stopPendingShadow = useCallback(() => {
|
|
84
96
|
clearTimeout(readyTimerRef.current);
|
|
85
97
|
budgetGenRef.current = null;
|
|
86
98
|
cancelPendingLoadRef.current();
|
|
99
|
+
stopHoldRef.current();
|
|
87
100
|
pendingCommitRef.current = null;
|
|
88
101
|
visuallyReadyGenRef.current = null;
|
|
89
102
|
}, []);
|
|
@@ -106,12 +119,58 @@ export function useShadowPreviewReload({
|
|
|
106
119
|
[stopPendingShadow, isRefreshingRef, pendingSeekRef],
|
|
107
120
|
);
|
|
108
121
|
|
|
122
|
+
const gestureSavesOnScreen = useCallback(() => {
|
|
123
|
+
const liveDoc = iframeRef.current?.contentDocument;
|
|
124
|
+
return liveDoc ? studioManualEditSavesIn(liveDoc) : 0;
|
|
125
|
+
}, [iframeRef]);
|
|
126
|
+
|
|
127
|
+
const waitThenRetry = useCallback(
|
|
128
|
+
(gen: number, why: string, wait: (retry: () => void) => () => void) => {
|
|
129
|
+
clearTimeout(readyTimerRef.current);
|
|
130
|
+
budgetGenRef.current = null;
|
|
131
|
+
stopHoldRef.current();
|
|
132
|
+
logReload("shadow-held", { gen, why });
|
|
133
|
+
stopHoldRef.current = wait(() => {
|
|
134
|
+
logReload("shadow-hold-ended", { gen, why });
|
|
135
|
+
promoteWhenReadyRef.current(gen);
|
|
136
|
+
});
|
|
137
|
+
return true;
|
|
138
|
+
},
|
|
139
|
+
[],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Nothing replaces the preview under a gesture, and a shadow requested before a gesture's save
|
|
143
|
+
// shows the file before it: once that save has landed, a fresh one loads instead.
|
|
144
|
+
const heldOrStale = useCallback(
|
|
145
|
+
(gen: number) => {
|
|
146
|
+
const liveDoc = iframeRef.current?.contentDocument;
|
|
147
|
+
if (liveDoc && isStudioManualEditGestureLiveIn(liveDoc))
|
|
148
|
+
return waitThenRetry(gen, "gesture", (retry) =>
|
|
149
|
+
afterStudioManualEditGestures(liveDoc, retry),
|
|
150
|
+
);
|
|
151
|
+
if (gestureSavesOnScreen() === savesAtRequestRef.current) return false;
|
|
152
|
+
if (isStudioEditSaving()) return waitThenRetry(gen, "save", afterStudioPendingEdits);
|
|
153
|
+
logReload("shadow-reloaded-after-gesture-save", { gen });
|
|
154
|
+
const url = new URL(shadowUrlRef.current, window.location.origin);
|
|
155
|
+
url.searchParams.set("_t", String(Date.now()));
|
|
156
|
+
beginShadowReloadRef.current(url.toString());
|
|
157
|
+
return true;
|
|
158
|
+
},
|
|
159
|
+
[iframeRef, gestureSavesOnScreen, waitThenRetry],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const shadowReadyToPromote = useCallback((gen: number) => {
|
|
163
|
+
const shadow = shadowIframeRef.current;
|
|
164
|
+
const pending = pendingCommitRef.current;
|
|
165
|
+
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
|
|
166
|
+
return shadow && pending && ready && gen === shadowGenRef.current ? { shadow, pending } : null;
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
109
169
|
const promoteWhenReady = useCallback(
|
|
110
170
|
(gen: number) => {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
|
|
171
|
+
const promotable = shadowReadyToPromote(gen);
|
|
172
|
+
if (!promotable || heldOrStale(gen)) return;
|
|
173
|
+
const { shadow, pending } = promotable;
|
|
115
174
|
stopPendingShadow();
|
|
116
175
|
// The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
|
|
117
176
|
const live = getAdapter();
|
|
@@ -119,6 +178,7 @@ export function useShadowPreviewReload({
|
|
|
119
178
|
const playing = usePlayerStore.getState().isPlaying;
|
|
120
179
|
live?.pause();
|
|
121
180
|
// The store takes the new document's timeline only now that it is the one on screen, and reads it there.
|
|
181
|
+
const retired = iframeRef.current;
|
|
122
182
|
iframeRef.current = shadow;
|
|
123
183
|
pending.commit();
|
|
124
184
|
shadowIframeRef.current = null;
|
|
@@ -126,17 +186,21 @@ export function useShadowPreviewReload({
|
|
|
126
186
|
applyPreviewAudioState();
|
|
127
187
|
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
|
|
128
188
|
if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
|
|
189
|
+
announcePreviewPromoted({ retired, live: shadow });
|
|
129
190
|
onPromotedRef.current?.();
|
|
130
191
|
thumbnailScheduler.setPreviewReloading(false);
|
|
131
192
|
},
|
|
132
193
|
[
|
|
133
194
|
stopPendingShadow,
|
|
195
|
+
shadowReadyToPromote,
|
|
196
|
+
heldOrStale,
|
|
134
197
|
getAdapter,
|
|
135
198
|
iframeRef,
|
|
136
199
|
attachIframeShortcutListeners,
|
|
137
200
|
applyPreviewAudioState,
|
|
138
201
|
],
|
|
139
202
|
);
|
|
203
|
+
promoteWhenReadyRef.current = promoteWhenReady;
|
|
140
204
|
|
|
141
205
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
142
206
|
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
|
@@ -216,13 +280,16 @@ export function useShadowPreviewReload({
|
|
|
216
280
|
stopPendingShadow();
|
|
217
281
|
budgetGenRef.current = gen;
|
|
218
282
|
budgetsSpentRef.current = 0;
|
|
283
|
+
shadowUrlRef.current = url;
|
|
284
|
+
savesAtRequestRef.current = gestureSavesOnScreen();
|
|
219
285
|
armReadyTimer(gen);
|
|
220
286
|
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
221
287
|
// Thumbnails of the edit wait for the new preview instead of competing with it.
|
|
222
288
|
thumbnailScheduler.setPreviewReloading(true);
|
|
223
289
|
},
|
|
224
|
-
[stopPendingShadow, armReadyTimer],
|
|
290
|
+
[stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
|
|
225
291
|
);
|
|
292
|
+
beginShadowReloadRef.current = beginShadowReload;
|
|
226
293
|
|
|
227
294
|
// Composition switch (not an edit reload): drop any in-flight shadow.
|
|
228
295
|
const resetPreviewSlots = useCallback(() => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { syncStoredAutomationFromPreview } from "./automationStoreSync";
|
|
3
|
+
import { syncStoredAutomationFromPreview, syncStoredElementAttribute } from "./automationStoreSync";
|
|
4
4
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
5
5
|
|
|
6
6
|
const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
|
|
@@ -32,7 +32,24 @@ beforeEach(() => {
|
|
|
32
32
|
usePlayerStore.getState().reset();
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
describe("syncStoredElementAttribute", () => {
|
|
36
|
+
it("records a saved fade on the stored clip, so the timeline draws what the file holds", () => {
|
|
37
|
+
usePlayerStore.setState({ elements: [el({ fadeIn: 1 })] });
|
|
38
|
+
syncStoredElementAttribute(el(), "data-fade-in", "2.5");
|
|
39
|
+
expect(usePlayerStore.getState().elements[0]?.fadeIn).toBe(2.5);
|
|
40
|
+
syncStoredElementAttribute(el(), "data-fade-in", null);
|
|
41
|
+
expect(usePlayerStore.getState().elements[0]?.fadeIn).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
35
45
|
describe("syncStoredAutomationFromPreview", () => {
|
|
46
|
+
it("reads back a fade an undo restored on the preview", () => {
|
|
47
|
+
usePlayerStore.setState({ elements: [el({ fadeOut: 2 })] });
|
|
48
|
+
syncStoredAutomationFromPreview(previewWith({ "data-fade-in": "0.6" }));
|
|
49
|
+
const stored = usePlayerStore.getState().elements[0];
|
|
50
|
+
expect([stored?.fadeIn, stored?.fadeOut]).toEqual([0.6, undefined]);
|
|
51
|
+
});
|
|
52
|
+
|
|
36
53
|
it("reads the element the timeline bound, not an earlier same-id copy in a sub-composition", () => {
|
|
37
54
|
usePlayerStore.setState({ elements: [el({ hfId: "hf-root" })] });
|
|
38
55
|
const doc = document.implementation.createHTMLDocument("preview");
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
|
|
17
17
|
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
18
18
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
19
|
+
import {
|
|
20
|
+
HF_AUDIO_FADE_IN_ATTR,
|
|
21
|
+
HF_AUDIO_FADE_OUT_ATTR,
|
|
22
|
+
readElementFades,
|
|
23
|
+
readFadeSeconds,
|
|
24
|
+
} from "@hyperframes/core/audio-fade";
|
|
19
25
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
20
26
|
import { groupInfoFor } from "./timelineGroupInfo";
|
|
21
27
|
import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
|
-
* Re-read every element's automation
|
|
30
|
+
* Re-read every element's automation, FX-chain and fade attributes from the preview
|
|
25
31
|
* document, for a change that reached the DOM without going through this store.
|
|
26
32
|
*
|
|
27
33
|
* That is undo and redo. A soft restore patches the reverted attributes onto the
|
|
@@ -34,21 +40,21 @@ import { getTimelineElementIdentity, previewElementFinder } from "./timelineElem
|
|
|
34
40
|
* reverted is only known by looking.
|
|
35
41
|
*/
|
|
36
42
|
/**
|
|
37
|
-
* What an element's
|
|
43
|
+
* What an element's six synced fields SHOULD read, given the preview.
|
|
38
44
|
*
|
|
39
|
-
* Its own
|
|
45
|
+
* Its own four come off its node; the other two are its copy of what its group
|
|
40
46
|
* carries. The timeline derives a group's lanes and chain from these mirrors,
|
|
41
|
-
* never from the group element
|
|
42
|
-
*
|
|
43
|
-
* group node through the ordinary element path, which used to refresh an
|
|
44
|
-
* element's own two fields and nothing else. So the group's row went on reading
|
|
45
|
-
* the value it was born with, and its `∿` never appeared.
|
|
47
|
+
* never from the group element, and automating a group writes `data-automation`
|
|
48
|
+
* on the group node through the ordinary element path, so both halves are re-read.
|
|
46
49
|
*/
|
|
47
50
|
function syncedFields(doc: Document, element: TimelineElement, node: Element) {
|
|
48
51
|
const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
|
|
52
|
+
const fades = readElementFades(node);
|
|
49
53
|
return {
|
|
50
54
|
automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
|
|
51
55
|
fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
|
|
56
|
+
fadeIn: storedFade(fades.fadeIn),
|
|
57
|
+
fadeOut: storedFade(fades.fadeOut),
|
|
52
58
|
audioGroupAutomation: group?.automation,
|
|
53
59
|
audioGroupFxChain: group?.fxChain,
|
|
54
60
|
};
|
|
@@ -74,27 +80,33 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
|
|
|
74
80
|
});
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
/** Discovery's rule (applyFadeMetadataFromElement): a fade of 0 is no fade. */
|
|
84
|
+
const storedFade = (seconds: number) => (seconds > 0 ? seconds : undefined);
|
|
85
|
+
|
|
86
|
+
const STORED_FIELD: Record<string, (value: string | null) => Partial<TimelineElement>> = {
|
|
87
|
+
[HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ automation: value ?? undefined }),
|
|
88
|
+
[HF_AUDIO_FX_ATTR]: (value) => ({ fxChain: value ?? undefined }),
|
|
89
|
+
[HF_AUDIO_FADE_IN_ATTR]: (value) => ({ fadeIn: storedFade(readFadeSeconds(value)) }),
|
|
90
|
+
[HF_AUDIO_FADE_OUT_ATTR]: (value) => ({ fadeOut: storedFade(readFadeSeconds(value)) }),
|
|
80
91
|
};
|
|
81
92
|
|
|
82
|
-
/** Record a saved automation
|
|
93
|
+
/** Record a saved automation, FX-chain or fade value on one element's stored copy. */
|
|
83
94
|
export function syncStoredElementAttribute(
|
|
84
95
|
target: TimelineElement,
|
|
85
96
|
attr: string,
|
|
86
97
|
value: string | null,
|
|
87
98
|
): void {
|
|
88
|
-
const
|
|
89
|
-
if (!
|
|
99
|
+
const fields = STORED_FIELD[attr]?.(value);
|
|
100
|
+
if (!fields) return;
|
|
90
101
|
const key = getTimelineElementIdentity(target);
|
|
91
|
-
const
|
|
102
|
+
const same = (element: TimelineElement) =>
|
|
103
|
+
(Object.keys(fields) as (keyof TimelineElement)[]).every((k) => element[k] === fields[k]);
|
|
92
104
|
usePlayerStore.setState((state) => {
|
|
93
105
|
let changed = false;
|
|
94
106
|
const elements = state.elements.map((element) => {
|
|
95
|
-
if (getTimelineElementIdentity(element) !== key || element
|
|
107
|
+
if (getTimelineElementIdentity(element) !== key || same(element)) return element;
|
|
96
108
|
changed = true;
|
|
97
|
-
return { ...element,
|
|
109
|
+
return { ...element, ...fields };
|
|
98
110
|
});
|
|
99
111
|
return changed ? { elements } : {};
|
|
100
112
|
});
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -17,6 +17,24 @@ export function onPreviewContentReplaced(
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const PREVIEW_PROMOTED = "hf-preview-promoted";
|
|
21
|
+
export type PreviewPromotion = { retired: HTMLIFrameElement | null; live: HTMLIFrameElement };
|
|
22
|
+
|
|
23
|
+
export function announcePreviewPromoted(promotion: PreviewPromotion): void {
|
|
24
|
+
promotion.live.ownerDocument.dispatchEvent(
|
|
25
|
+
new CustomEvent(PREVIEW_PROMOTED, { detail: promotion }),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function onPreviewPromoted(
|
|
30
|
+
doc: Document,
|
|
31
|
+
listener: (promotion: PreviewPromotion) => void,
|
|
32
|
+
): () => void {
|
|
33
|
+
const handle = (event: Event) => listener((event as CustomEvent<PreviewPromotion>).detail);
|
|
34
|
+
doc.addEventListener(PREVIEW_PROMOTED, handle);
|
|
35
|
+
return () => doc.removeEventListener(PREVIEW_PROMOTED, handle);
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
|
|
21
39
|
const doc = iframe?.contentDocument;
|
|
22
40
|
const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
|
|
@@ -253,6 +253,32 @@
|
|
|
253
253
|
border-radius: var(--timeline-clip-audio-radius);
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
.timeline-fade-tab {
|
|
257
|
+
position: absolute;
|
|
258
|
+
border-radius: 2px;
|
|
259
|
+
background: var(--timeline-fade-dot);
|
|
260
|
+
box-shadow:
|
|
261
|
+
0 0 0 1px var(--timeline-fade-dot-ring),
|
|
262
|
+
0 1px 3px var(--timeline-fade-dot-ring);
|
|
263
|
+
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.timeline-fade-handle:hover .timeline-fade-tab,
|
|
267
|
+
.timeline-fade-handle[data-dragging] .timeline-fade-tab {
|
|
268
|
+
transform: scaleY(1.2);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.timeline-fade-handle:focus-visible .timeline-fade-tab {
|
|
272
|
+
outline: 2px solid var(--timeline-accent);
|
|
273
|
+
outline-offset: 2px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@media (prefers-reduced-motion: reduce) {
|
|
277
|
+
.timeline-fade-tab {
|
|
278
|
+
transition: none;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
256
282
|
.timeline-clip.is-audio.is-hovered {
|
|
257
283
|
background-color: var(--timeline-clip-audio-bg-hover);
|
|
258
284
|
}
|
package/src/styles/studio.css
CHANGED
package/src/styles/theme.css
CHANGED
|
@@ -77,6 +77,16 @@
|
|
|
77
77
|
--color-media: light-dark(oklch(0.5 0.1 215), #00e3ff);
|
|
78
78
|
--color-container: light-dark(oklch(0.55 0.13 70), #f5a623);
|
|
79
79
|
|
|
80
|
+
/* Code editor syntax: One Light and One Dark hues, held at 4.5:1 on a line, the active line and a selection on it. */
|
|
81
|
+
--color-code-keyword: light-dark(oklch(0.509 0.211 329), oklch(0.829 0.164 318));
|
|
82
|
+
--color-code-name: light-dark(oklch(0.504 0.179 28), oklch(0.856 0.145 17));
|
|
83
|
+
--color-code-function: light-dark(oklch(0.492 0.193 263), oklch(0.805 0.121 245));
|
|
84
|
+
--color-code-constant: light-dark(oklch(0.495 0.13 50), oklch(0.815 0.11 50));
|
|
85
|
+
--color-code-type: light-dark(oklch(0.48 0.12 92), oklch(0.805 0.1 92));
|
|
86
|
+
--color-code-operator: light-dark(oklch(0.471 0.127 237), oklch(0.793 0.092 206));
|
|
87
|
+
--color-code-comment: light-dark(oklch(0.48 0.009 279), oklch(0.806 0.03 262));
|
|
88
|
+
--color-code-string: light-dark(oklch(0.468 0.142 143), oklch(0.793 0.11 133));
|
|
89
|
+
|
|
80
90
|
/* The two stops of the HeyGen brand gradient, worn by the header logo. Fixed brand values
|
|
81
91
|
* rather than roles, so they are not reachable as a background or text colour by mistake. */
|
|
82
92
|
--color-brand-gradient-from: #06e3fa;
|
|
@@ -17,6 +17,7 @@ export type ClipboardPayload =
|
|
|
17
17
|
clips: TimelineClipboardClip[];
|
|
18
18
|
sourceFile: string;
|
|
19
19
|
projectId?: string;
|
|
20
|
+
copiedInComposition?: string;
|
|
20
21
|
}
|
|
21
22
|
| {
|
|
22
23
|
kind: "dom-element";
|
|
@@ -63,7 +64,15 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
|
|
63
64
|
});
|
|
64
65
|
if (clips.length === 0) return null;
|
|
65
66
|
const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined;
|
|
66
|
-
|
|
67
|
+
const copiedInComposition =
|
|
68
|
+
typeof obj.copiedInComposition === "string" ? obj.copiedInComposition : undefined;
|
|
69
|
+
return {
|
|
70
|
+
kind: "timeline-clip",
|
|
71
|
+
clips,
|
|
72
|
+
sourceFile: obj.sourceFile,
|
|
73
|
+
projectId,
|
|
74
|
+
copiedInComposition,
|
|
75
|
+
};
|
|
67
76
|
}
|
|
68
77
|
if (obj.kind === "dom-element") {
|
|
69
78
|
if (typeof obj.html !== "string") return null;
|
|
@@ -39,6 +39,27 @@ export function hasStudioPendingEdits(): boolean {
|
|
|
39
39
|
return pendingEditPromises.size > 0 || focusedField() !== null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function isStudioEditSaving(): boolean {
|
|
43
|
+
return pendingEditPromises.size > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function afterStudioPendingEdits(run: () => void): () => void {
|
|
47
|
+
let waiting = true;
|
|
48
|
+
const check = () => {
|
|
49
|
+
if (!waiting) return;
|
|
50
|
+
if (isStudioEditSaving()) {
|
|
51
|
+
void Promise.allSettled([...pendingEditPromises]).then(check);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
waiting = false;
|
|
55
|
+
run();
|
|
56
|
+
};
|
|
57
|
+
check();
|
|
58
|
+
return () => {
|
|
59
|
+
waiting = false;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export function trackStudioPendingEdit(
|
|
43
64
|
result: Promise<unknown> | unknown,
|
|
44
65
|
): Promise<unknown> | undefined {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { ThemeToggle } from "../components/ThemeToggle";
|
|
7
|
+
import { cleanupMounted, mountHost } from "../components/ui/mountHost.testHelpers";
|
|
8
|
+
import { savedStudioTheme, shownStudioTheme } from "./studioTheme";
|
|
9
|
+
|
|
10
|
+
const KEY = "hf-studio-ui-preferences";
|
|
11
|
+
const page = new DOMParser().parseFromString(
|
|
12
|
+
readFileSync(path.join(__dirname, "../../index.html"), "utf8"),
|
|
13
|
+
"text/html",
|
|
14
|
+
);
|
|
15
|
+
const scripts = [...page.querySelectorAll("script")];
|
|
16
|
+
const boot = scripts.find((script) => !script.type)?.textContent ?? "";
|
|
17
|
+
|
|
18
|
+
function store(theme: unknown) {
|
|
19
|
+
localStorage.setItem(KEY, JSON.stringify({ theme }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function boots(): "light" | "dark" {
|
|
23
|
+
new Function(boot)();
|
|
24
|
+
return shownStudioTheme();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanupMounted();
|
|
29
|
+
localStorage.clear();
|
|
30
|
+
delete document.documentElement.dataset.theme;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("Studio's theme", () => {
|
|
34
|
+
it.each([["light"], ["dark"], [undefined], ["neon"]])(
|
|
35
|
+
"boots to the theme the owner picks (saved %s)",
|
|
36
|
+
(saved) => {
|
|
37
|
+
store(saved);
|
|
38
|
+
expect(boots()).toBe(savedStudioTheme());
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it.each([["null"], ["{not json"], ['"dark"']])("boots to the owner's pick from %s", (raw) => {
|
|
43
|
+
localStorage.setItem(KEY, raw);
|
|
44
|
+
expect(boots()).toBe(savedStudioTheme());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("opens light the first time", () => {
|
|
48
|
+
expect(boots()).toBe("light");
|
|
49
|
+
expect(savedStudioTheme()).toBe("light");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("runs the boot script before the app's module", () => {
|
|
53
|
+
expect(boot).toContain(`"${KEY}"`);
|
|
54
|
+
expect(scripts.findIndex((script) => !script.type)).toBeLessThan(
|
|
55
|
+
scripts.findIndex((script) => script.type === "module"),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("flips the document between light and dark and keeps the choice", async () => {
|
|
60
|
+
store("dark");
|
|
61
|
+
const host = mountHost(<ThemeToggle />);
|
|
62
|
+
const button = () => host.querySelector("button")!;
|
|
63
|
+
expect(button().getAttribute("aria-label")).toBe("Switch to light theme");
|
|
64
|
+
|
|
65
|
+
await act(async () => button().click());
|
|
66
|
+
expect(document.documentElement.dataset.theme).toBe("paper");
|
|
67
|
+
expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("light");
|
|
68
|
+
expect(button().getAttribute("aria-label")).toBe("Switch to dark theme");
|
|
69
|
+
|
|
70
|
+
await act(async () => button().click());
|
|
71
|
+
expect(document.documentElement.dataset.theme).toBeUndefined();
|
|
72
|
+
expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("dark");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("follows the theme on screen, not only its own clicks", async () => {
|
|
76
|
+
store("light");
|
|
77
|
+
const host = mountHost(<ThemeToggle />);
|
|
78
|
+
expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to light theme");
|
|
79
|
+
await act(async () => {
|
|
80
|
+
document.documentElement.dataset.theme = "paper";
|
|
81
|
+
});
|
|
82
|
+
expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to dark theme");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("the reveal", () => {
|
|
87
|
+
function stubTransition() {
|
|
88
|
+
const animate = vi.fn();
|
|
89
|
+
Object.assign(document.documentElement, { animate });
|
|
90
|
+
const markers: (string | undefined)[] = [];
|
|
91
|
+
const finish: (() => void)[] = [];
|
|
92
|
+
const start = vi.fn((update: () => void) => {
|
|
93
|
+
markers.push(document.documentElement.dataset.themeReveal);
|
|
94
|
+
update();
|
|
95
|
+
return { ready: Promise.resolve(), finished: new Promise<void>((done) => finish.push(done)) };
|
|
96
|
+
});
|
|
97
|
+
Object.assign(document, { startViewTransition: start });
|
|
98
|
+
return { animate, start, markers, finish };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
Reflect.deleteProperty(document, "startViewTransition");
|
|
103
|
+
Reflect.deleteProperty(document.documentElement, "animate");
|
|
104
|
+
vi.unstubAllGlobals();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("grows from the button's centre when the keyboard presses it, marked while it runs", async () => {
|
|
108
|
+
const { animate, markers, finish } = stubTransition();
|
|
109
|
+
vi.stubGlobal("innerWidth", 1000);
|
|
110
|
+
vi.stubGlobal("innerHeight", 500);
|
|
111
|
+
const button = mountHost(<ThemeToggle />).querySelector("button")!;
|
|
112
|
+
button.getBoundingClientRect = () => ({ left: 890, top: 10, width: 20, height: 20 }) as DOMRect;
|
|
113
|
+
await act(async () => {
|
|
114
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
|
|
115
|
+
});
|
|
116
|
+
expect(markers).toEqual([""]);
|
|
117
|
+
expect(animate.mock.calls[0]![0].clipPath[1]).toMatch(/^circle\(129\.0\d*% at 90% 4%\)$/);
|
|
118
|
+
await act(async () => finish[0]!());
|
|
119
|
+
expect(document.documentElement.dataset.themeReveal).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps the marker for a second reveal that starts before the first ends", async () => {
|
|
123
|
+
const { finish } = stubTransition();
|
|
124
|
+
const button = mountHost(<ThemeToggle />).querySelector("button")!;
|
|
125
|
+
await act(async () => button.click());
|
|
126
|
+
await act(async () => button.click());
|
|
127
|
+
await act(async () => finish[0]!());
|
|
128
|
+
expect(document.documentElement.dataset.themeReveal).toBe("");
|
|
129
|
+
await act(async () => finish[1]!());
|
|
130
|
+
expect(document.documentElement.dataset.themeReveal).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("switches without the reveal under reduced motion", async () => {
|
|
134
|
+
const { start } = stubTransition();
|
|
135
|
+
vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduce") }));
|
|
136
|
+
const button = mountHost(<ThemeToggle />).querySelector("button")!;
|
|
137
|
+
await act(async () => button.click());
|
|
138
|
+
expect(start).not.toHaveBeenCalled();
|
|
139
|
+
expect(document.documentElement.dataset.theme).toBe("paper");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Studio's own app opens on the toggle's saved choice, else light. Light is `data-theme="paper"`
|
|
2
|
+
// on the document element, the hook theme.css keys on; dark is no attribute. index.html applies
|
|
3
|
+
// `savedStudioTheme` before the first paint; studioTheme.test.tsx holds the two together.
|
|
4
|
+
import { useSyncExternalStore } from "react";
|
|
5
|
+
import {
|
|
6
|
+
readStudioUiPreferences,
|
|
7
|
+
writeStudioUiPreferences,
|
|
8
|
+
type StudioTheme,
|
|
9
|
+
} from "./studioUiPreferences";
|
|
10
|
+
|
|
11
|
+
export function savedStudioTheme(): StudioTheme {
|
|
12
|
+
return readStudioUiPreferences().theme ?? "light";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function shownStudioTheme(): StudioTheme {
|
|
16
|
+
return document.documentElement.dataset.theme === "paper" ? "light" : "dark";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setStudioTheme(theme: StudioTheme): void {
|
|
20
|
+
writeStudioUiPreferences({ theme });
|
|
21
|
+
const root = document.documentElement;
|
|
22
|
+
if (theme === "light") root.dataset.theme = "paper";
|
|
23
|
+
else delete root.dataset.theme;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function subscribe(listener: () => void): () => void {
|
|
27
|
+
const observer = new MutationObserver(listener);
|
|
28
|
+
observer.observe(document.documentElement, { attributeFilter: ["data-theme"] });
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useShownStudioTheme(): StudioTheme {
|
|
33
|
+
return useSyncExternalStore(subscribe, shownStudioTheme);
|
|
34
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { SerializedDockview } from "dockview-react";
|
|
|
2
2
|
import { parseDockLayout } from "../components/dock/dockLayoutSchema";
|
|
3
3
|
|
|
4
4
|
export type TimelineTimeDisplayMode = "time" | "frame";
|
|
5
|
+
export type StudioTheme = "light" | "dark";
|
|
5
6
|
|
|
6
7
|
export interface StudioUiPreferences {
|
|
7
8
|
timelineVisible?: boolean;
|
|
@@ -41,6 +42,7 @@ export interface StudioUiPreferences {
|
|
|
41
42
|
* intentionally scoped to one mount.
|
|
42
43
|
*/
|
|
43
44
|
agentToolsEnabled?: boolean;
|
|
45
|
+
theme?: StudioTheme;
|
|
44
46
|
/** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
|
|
45
47
|
dockLayout?: SerializedDockview;
|
|
46
48
|
}
|
|
@@ -128,6 +130,7 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
|
|
|
128
130
|
if (typeof parsed.rippleEditEnabled === "boolean") {
|
|
129
131
|
preferences.rippleEditEnabled = parsed.rippleEditEnabled;
|
|
130
132
|
}
|
|
133
|
+
if (parsed.theme === "light" || parsed.theme === "dark") preferences.theme = parsed.theme;
|
|
131
134
|
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
|
|
132
135
|
preferences.timeDisplayMode = parsed.timeDisplayMode;
|
|
133
136
|
}
|