@hyperframes/studio 0.8.105 → 0.8.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -506,34 +506,6 @@ export function shouldShowTimelineShortcutHint(
|
|
|
506
506
|
return scrollHeight - clientHeight <= 1;
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
export function shouldHandleTimelineDeleteKey(input: {
|
|
510
|
-
key: string;
|
|
511
|
-
metaKey?: boolean;
|
|
512
|
-
ctrlKey?: boolean;
|
|
513
|
-
altKey?: boolean;
|
|
514
|
-
target?: EventTarget | null;
|
|
515
|
-
}): boolean {
|
|
516
|
-
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
|
517
|
-
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
|
518
|
-
const target =
|
|
519
|
-
input.target && typeof input.target === "object"
|
|
520
|
-
? (input.target as {
|
|
521
|
-
tagName?: string;
|
|
522
|
-
isContentEditable?: boolean;
|
|
523
|
-
closest?: (selector: string) => Element | null;
|
|
524
|
-
})
|
|
525
|
-
: null;
|
|
526
|
-
if (target) {
|
|
527
|
-
const tag = target.tagName?.toLowerCase() ?? "";
|
|
528
|
-
if (target.isContentEditable) return false;
|
|
529
|
-
if (["input", "textarea", "select"].includes(tag)) return false;
|
|
530
|
-
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return true;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
509
|
/* ── Asset drop ───────────────────────────────────────────────────── */
|
|
538
510
|
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
|
539
511
|
if (trackOrder.length === 0) return 0;
|
|
@@ -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
|
+
}
|
|
@@ -261,3 +261,46 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
|
|
261
261
|
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
262
262
|
});
|
|
263
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("usePlaybackKeyboard — a focused native player owns its keys", () => {
|
|
266
|
+
const KEYS = [
|
|
267
|
+
{ code: "ArrowLeft", key: "ArrowLeft" },
|
|
268
|
+
{ code: "ArrowRight", key: "ArrowRight" },
|
|
269
|
+
{ code: "Space", key: " " },
|
|
270
|
+
{ code: "KeyJ", key: "j" },
|
|
271
|
+
{ code: "KeyK", key: "k" },
|
|
272
|
+
{ code: "KeyL", key: "l" },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
it.each(["video", "audio"])(
|
|
276
|
+
"leaves the timeline alone while a <%s controls> has focus",
|
|
277
|
+
(tag) => {
|
|
278
|
+
const { dispatch, spies } = setupHook();
|
|
279
|
+
const player = document.createElement(tag);
|
|
280
|
+
player.setAttribute("controls", "");
|
|
281
|
+
document.body.append(player);
|
|
282
|
+
const before = usePlayerStore.getState().currentTime;
|
|
283
|
+
for (const init of KEYS) {
|
|
284
|
+
const event = keydown(init);
|
|
285
|
+
Object.defineProperty(event, "target", { value: player });
|
|
286
|
+
dispatch(event);
|
|
287
|
+
expect(event.defaultPrevented, init.code).toBe(false);
|
|
288
|
+
}
|
|
289
|
+
expect(spies.seek).not.toHaveBeenCalled();
|
|
290
|
+
expect(spies.play).not.toHaveBeenCalled();
|
|
291
|
+
expect(spies.playBackward).not.toHaveBeenCalled();
|
|
292
|
+
expect(spies.pause).not.toHaveBeenCalled();
|
|
293
|
+
expect(usePlayerStore.getState().currentTime).toBe(before);
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
it("still steps the timeline from a player without controls", () => {
|
|
298
|
+
const { dispatch } = setupHook();
|
|
299
|
+
const video = document.createElement("video");
|
|
300
|
+
document.body.append(video);
|
|
301
|
+
const event = keydown({ code: "ArrowRight", key: "ArrowRight" });
|
|
302
|
+
Object.defineProperty(event, "target", { value: video });
|
|
303
|
+
dispatch(event);
|
|
304
|
+
expect(event.defaultPrevented).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -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
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* is active and the user is navigating caption segments).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { ownsPlainKeys } from "../../utils/typingTarget";
|
|
10
10
|
|
|
11
11
|
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
|
12
12
|
|
|
@@ -15,21 +15,17 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
|
15
15
|
"a[href]",
|
|
16
16
|
"[role='button']",
|
|
17
17
|
"[role='checkbox']",
|
|
18
|
-
"[role='combobox']",
|
|
19
18
|
"[role='menuitem']",
|
|
20
19
|
// Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
|
|
21
20
|
"[role='menuitemradio']",
|
|
22
21
|
"[role='radio']",
|
|
23
|
-
"[role='slider']",
|
|
24
22
|
"[role='spinbutton']",
|
|
25
|
-
"[role='switch']",
|
|
26
|
-
"[role='textbox']",
|
|
27
23
|
].join(",");
|
|
28
24
|
|
|
29
25
|
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
|
30
26
|
// Anything the user is typing into owns its keys outright, editable elements
|
|
31
|
-
// included: a letter claimed here never reaches the text.
|
|
32
|
-
if (
|
|
27
|
+
// included: a letter claimed here never reaches the text. So does a native player.
|
|
28
|
+
if (ownsPlainKeys(target)) return true;
|
|
33
29
|
if (!target || typeof target !== "object") return false;
|
|
34
30
|
const candidate = target as { closest?: unknown };
|
|
35
31
|
if (typeof candidate.closest !== "function") return false;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isVideoAudible } from "./timelineElementHelpers";
|
|
3
|
+
|
|
4
|
+
describe("isVideoAudible — the compiler's data-has-audio rule", () => {
|
|
5
|
+
it("explicit data-has-audio wins", () => {
|
|
6
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: "true", muted: false })).toBe(true);
|
|
7
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: "false", muted: false })).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
it("no attribute: an unmuted video is audible, a muted one is not", () => {
|
|
10
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: null, muted: false })).toBe(true);
|
|
11
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: undefined, muted: true })).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("never true for non-video without the attribute", () => {
|
|
14
|
+
expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -220,12 +220,26 @@ function readVolume(el: Element, media: Element): number | undefined {
|
|
|
220
220
|
return Number.isFinite(volume) ? volume : undefined;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/** The compiler's rule (timingCompiler): explicit data-has-audio wins; otherwise an unmuted <video> is audible. */
|
|
224
|
+
export function isVideoAudible(opts: {
|
|
225
|
+
tag: string;
|
|
226
|
+
hasAudioAttr: string | null | undefined;
|
|
227
|
+
muted: boolean;
|
|
228
|
+
}): boolean {
|
|
229
|
+
if (opts.hasAudioAttr === "true") return true;
|
|
230
|
+
if (opts.hasAudioAttr === "false" || opts.hasAudioAttr === "") return false;
|
|
231
|
+
return opts.tag.toLowerCase() === "video" && !opts.muted;
|
|
232
|
+
}
|
|
233
|
+
|
|
223
234
|
/** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
|
|
224
235
|
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
225
236
|
const media = resolveMediaElement(el) ?? el;
|
|
226
237
|
const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
|
|
227
|
-
const
|
|
228
|
-
|
|
238
|
+
const sound = isVideoAudible({
|
|
239
|
+
tag: el.tagName,
|
|
240
|
+
hasAudioAttr: el.getAttribute("data-has-audio"),
|
|
241
|
+
muted,
|
|
242
|
+
});
|
|
229
243
|
setOptional(entry, "hasAudio", sound ? true : undefined);
|
|
230
244
|
setOptional(entry, "muted", muted ? true : undefined);
|
|
231
245
|
setOptional(entry, "volume", readVolume(el, media));
|
|
@@ -19,6 +19,8 @@ describe("timeline viewport budgets", () => {
|
|
|
19
19
|
interactionP95Ms: 50,
|
|
20
20
|
constrainedInteractionP95Ms: 75,
|
|
21
21
|
constrainedFrameIntervalP95Ms: 75,
|
|
22
|
+
ciVirtualizedInteractionP95Ms: 58.3,
|
|
23
|
+
ciVirtualizedFrameIntervalP95Ms: 25,
|
|
22
24
|
longTaskLimitMs: 50,
|
|
23
25
|
constrainedLongTaskLimitMs: 300,
|
|
24
26
|
posterCoverageRatio: 0.9,
|
|
@@ -28,6 +28,8 @@ export interface TimelineViewportBudgets {
|
|
|
28
28
|
frameIntervalP95Ms: number;
|
|
29
29
|
constrainedInteractionP95Ms: number;
|
|
30
30
|
constrainedFrameIntervalP95Ms: number;
|
|
31
|
+
ciVirtualizedInteractionP95Ms: number;
|
|
32
|
+
ciVirtualizedFrameIntervalP95Ms: number;
|
|
31
33
|
longTaskLimitMs: number;
|
|
32
34
|
constrainedLongTaskLimitMs: number;
|
|
33
35
|
memoryReturnToleranceRatio: number;
|
|
@@ -86,6 +88,8 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
|
|
|
86
88
|
frameIntervalP95Ms: 33.3,
|
|
87
89
|
constrainedInteractionP95Ms: 75,
|
|
88
90
|
constrainedFrameIntervalP95Ms: 75,
|
|
91
|
+
ciVirtualizedInteractionP95Ms: 58.3,
|
|
92
|
+
ciVirtualizedFrameIntervalP95Ms: 25,
|
|
89
93
|
longTaskLimitMs: 50,
|
|
90
94
|
constrainedLongTaskLimitMs: 300,
|
|
91
95
|
memoryReturnToleranceRatio: 0.15,
|
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;
|
|
@@ -195,6 +195,7 @@ export interface SoftReloadOptions {
|
|
|
195
195
|
currentTimeOverride?: number;
|
|
196
196
|
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
|
197
197
|
authoredHtml?: string;
|
|
198
|
+
reparse?: Element[];
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
/**
|
|
@@ -251,7 +252,7 @@ export function applySoftReload(
|
|
|
251
252
|
scriptText: string,
|
|
252
253
|
options: SoftReloadOptions = {},
|
|
253
254
|
): SoftReloadResult {
|
|
254
|
-
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
|
255
|
+
const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
|
|
255
256
|
if (!iframe || !scriptText) return "cannot-soft-reload";
|
|
256
257
|
|
|
257
258
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -329,7 +330,7 @@ export function applySoftReload(
|
|
|
329
330
|
// fallow-ignore-next-line complexity
|
|
330
331
|
const doReload = () => {
|
|
331
332
|
const timelines = win.__timelines;
|
|
332
|
-
const allTargets: Element[] = [];
|
|
333
|
+
const allTargets: Element[] = [...reparse];
|
|
333
334
|
|
|
334
335
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
335
336
|
// composition's timeline (and its children on the global timeline) intact.
|