@hyperframes/studio 0.8.76 → 0.8.77
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-BkDxgSKG.js → hyperframes-player-BSDTJ9EF.js} +1 -1
- package/dist/assets/{index-DeHir1up.js → index-2GgHjQq-.js} +148 -148
- package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
- package/dist/assets/{index-COX0scwJ.js → index-CsFCRdMo.js} +1 -1
- package/dist/assets/{index-FmKUmYhC.js → index-DcxWQ7Za.js} +1 -1
- package/dist/{chunk-IYDE66BY.js → chunk-5VANA2TG.js} +7 -1
- package/dist/{chunk-IYDE66BY.js.map → chunk-5VANA2TG.js.map} +1 -1
- package/dist/{domEditingLayers-2XBFXXSW.js → domEditingLayers-IGPLTI5E.js} +2 -2
- package/dist/index.html +2 -2
- package/dist/index.js +4125 -4009
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/editor/domEditingElement.ts +13 -0
- package/src/components/editor/gsapLivePreview.test.ts +63 -0
- package/src/components/editor/gsapLivePreview.ts +2 -14
- package/src/components/editor/propertyPanelHelpers.ts +4 -6
- package/src/components/nle/NLEPreview.test.ts +110 -7
- package/src/components/nle/NLEPreview.tsx +51 -46
- package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
- package/src/components/nle/previewZoom.test.ts +42 -0
- package/src/components/nle/previewZoom.ts +33 -0
- package/src/hooks/useDomEditWiring.ts +1 -3
- package/src/hooks/useGestureRecording.test.tsx +1 -1
- package/src/hooks/useGestureRecording.ts +6 -10
- package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
- package/src/hooks/useGsapTweenCache.ts +5 -13
- package/src/player/lib/automationStoreSync.test.ts +24 -0
- package/src/player/lib/automationStoreSync.ts +3 -9
- package/src/player/lib/playbackScrub.ts +1 -1
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineElementHelpers.ts +33 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
- package/src/player/lib/timelineIframeHelpers.ts +13 -12
- package/src/utils/studioUiPreferences.test.ts +9 -3
- package/src/utils/studioUiPreferences.ts +0 -20
- /package/dist/{domEditingLayers-2XBFXXSW.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
|
@@ -17,6 +17,12 @@ export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
|
|
|
17
17
|
const ZOOM_SENSITIVITY = 0.007;
|
|
18
18
|
const MAX_DELTA = 10;
|
|
19
19
|
|
|
20
|
+
export const isFitZoom = (zoomPercent: number) => Math.abs(zoomPercent - 100) < 0.5;
|
|
21
|
+
|
|
22
|
+
export function isPreviewAtFit(state: PreviewZoomState): boolean {
|
|
23
|
+
return isFitZoom(state.zoomPercent) && Math.abs(state.panX) < 0.1 && Math.abs(state.panY) < 0.1;
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
export function toDomPrecision(value: number): number {
|
|
21
27
|
return Math.round(value * 10000) / 10000;
|
|
22
28
|
}
|
|
@@ -162,3 +168,30 @@ export function resolvePreviewWheelPan(input: {
|
|
|
162
168
|
...pan,
|
|
163
169
|
};
|
|
164
170
|
}
|
|
171
|
+
|
|
172
|
+
export interface PreviewVisibleRegion {
|
|
173
|
+
left: number;
|
|
174
|
+
top: number;
|
|
175
|
+
width: number;
|
|
176
|
+
height: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function resolvePreviewVisibleRegion(input: {
|
|
180
|
+
state: PreviewZoomState;
|
|
181
|
+
viewportWidth: number;
|
|
182
|
+
viewportHeight: number;
|
|
183
|
+
contentWidth: number;
|
|
184
|
+
contentHeight: number;
|
|
185
|
+
}): PreviewVisibleRegion {
|
|
186
|
+
const scale = clampPreviewZoomPercent(input.state.zoomPercent) / 100;
|
|
187
|
+
const axis = (pan: number, view: number, content: number): [number, number] => {
|
|
188
|
+
const size = content * scale;
|
|
189
|
+
if (!(size > 0)) return [0, 1];
|
|
190
|
+
const at = (edge: number) => Math.min(1, Math.max(0, (edge - pan + size / 2) / size));
|
|
191
|
+
const start = at(-view / 2);
|
|
192
|
+
return [start, at(view / 2) - start];
|
|
193
|
+
};
|
|
194
|
+
const [left, width] = axis(input.state.panX, input.viewportWidth, input.contentWidth);
|
|
195
|
+
const [top, height] = axis(input.state.panY, input.viewportHeight, input.contentHeight);
|
|
196
|
+
return { left, top, width, height };
|
|
197
|
+
}
|
|
@@ -207,9 +207,7 @@ export function useDomEditWiring(params: UseDomEditWiringParams) {
|
|
|
207
207
|
} = useGsapAnimationsForElement(
|
|
208
208
|
projectId ?? null,
|
|
209
209
|
gsapSourceFile,
|
|
210
|
-
domEditSelection
|
|
211
|
-
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
|
|
212
|
-
: null,
|
|
210
|
+
domEditSelection,
|
|
213
211
|
gsapCacheVersion,
|
|
214
212
|
// Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
|
|
215
213
|
// the live element and surface in the inspector — not just by #id match.
|
|
@@ -109,7 +109,7 @@ describe("useGestureRecording", () => {
|
|
|
109
109
|
act(() => root.unmount());
|
|
110
110
|
|
|
111
111
|
expect(cancelAnimationFrame).toHaveBeenCalledWith(17);
|
|
112
|
-
expect(set).toHaveBeenCalledWith(
|
|
112
|
+
expect(set).toHaveBeenCalledWith(element, {
|
|
113
113
|
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
|
|
114
114
|
});
|
|
115
115
|
expect(element.style.visibility).toBe("hidden");
|
|
@@ -30,8 +30,7 @@ interface BasePosition {
|
|
|
30
30
|
|
|
31
31
|
interface GsapRuntime {
|
|
32
32
|
timeline: { seek: (t: number) => void };
|
|
33
|
-
gsap: { set: (target:
|
|
34
|
-
selector: string;
|
|
33
|
+
gsap: { set: (target: Element, vars: Record<string, number | string>) => void };
|
|
35
34
|
element: HTMLElement;
|
|
36
35
|
startTime: number;
|
|
37
36
|
maxSeekTime: number;
|
|
@@ -81,12 +80,11 @@ function readBasePosition(element: HTMLElement, iframeEl: HTMLIFrameElement): Ba
|
|
|
81
80
|
function connectGsapRuntime(
|
|
82
81
|
element: HTMLElement,
|
|
83
82
|
iframeEl: HTMLIFrameElement,
|
|
84
|
-
selector: string | null,
|
|
85
83
|
elementEndTime: number | undefined,
|
|
86
84
|
): GsapRuntime | null {
|
|
87
85
|
try {
|
|
88
86
|
const win = iframeEl.contentWindow as Window & {
|
|
89
|
-
gsap?: { set: (t:
|
|
87
|
+
gsap?: { set: (t: Element, v: Record<string, number | string>) => void };
|
|
90
88
|
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
|
|
91
89
|
__player?: { getTime: () => number };
|
|
92
90
|
};
|
|
@@ -98,12 +96,11 @@ function connectGsapRuntime(
|
|
|
98
96
|
([key, value]) => key !== "__proxied" && typeof value?.seek === "function",
|
|
99
97
|
)?.[1] ?? null)
|
|
100
98
|
: null;
|
|
101
|
-
if (win?.gsap?.set && tl?.seek &&
|
|
99
|
+
if (win?.gsap?.set && tl?.seek && element.id) {
|
|
102
100
|
const tlDuration = tl.duration();
|
|
103
101
|
return {
|
|
104
102
|
timeline: tl,
|
|
105
103
|
gsap: win.gsap,
|
|
106
|
-
selector,
|
|
107
104
|
element,
|
|
108
105
|
startTime: win.__player?.getTime() ?? 0,
|
|
109
106
|
maxSeekTime:
|
|
@@ -126,7 +123,7 @@ function applyRuntimePreview(
|
|
|
126
123
|
const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
|
|
127
124
|
runtime.timeline.seek(seekTime);
|
|
128
125
|
runtime.element.style.setProperty("translate", "none");
|
|
129
|
-
runtime.gsap.set(runtime.
|
|
126
|
+
runtime.gsap.set(runtime.element, { ...properties });
|
|
130
127
|
runtime.element.style.visibility = "visible";
|
|
131
128
|
liveTime.notify(seekTime);
|
|
132
129
|
usePlayerStore.getState().setCurrentTime(seekTime);
|
|
@@ -256,7 +253,7 @@ function releaseRuntimePreview(r: RecordingRefs): void {
|
|
|
256
253
|
element.style.visibility = savedVisibility;
|
|
257
254
|
element.style.setProperty("translate", savedTranslate || "");
|
|
258
255
|
try {
|
|
259
|
-
runtime.gsap.set(runtime.
|
|
256
|
+
runtime.gsap.set(runtime.element, {
|
|
260
257
|
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
|
|
261
258
|
});
|
|
262
259
|
} catch {
|
|
@@ -324,8 +321,7 @@ export function useGestureRecording() {
|
|
|
324
321
|
r.scale = computeIframeScale(iframeEl);
|
|
325
322
|
|
|
326
323
|
// --- Phase 3: Connect to the iframe GSAP runtime ---
|
|
327
|
-
|
|
328
|
-
r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
|
|
324
|
+
r.runtime = connectGsapRuntime(element, iframeEl, elementEndTime);
|
|
329
325
|
// Clear the optimistic path offset only while a live runtime owns the
|
|
330
326
|
// preview. releaseRuntimePreview restores it on every exit path.
|
|
331
327
|
if (r.runtime && (base.cssOffX || base.cssOffY)) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
+
import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
|
|
8
|
+
installReactActEnvironment();
|
|
9
|
+
|
|
10
|
+
const dotTween = {
|
|
11
|
+
id: "dots",
|
|
12
|
+
targetSelector: ".dot",
|
|
13
|
+
method: "from",
|
|
14
|
+
position: 0,
|
|
15
|
+
properties: { opacity: 0 },
|
|
16
|
+
} as GsapAnimation;
|
|
17
|
+
vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import("./keyframeCacheAstLoad")>()),
|
|
19
|
+
fetchParsedAnimations: async () => ({ animations: [dotTween] }),
|
|
20
|
+
}));
|
|
21
|
+
const { useGsapAnimationsForElement } = await import("./useGsapTweenCache");
|
|
22
|
+
|
|
23
|
+
it("attributes a class tween by the selected element, not an earlier same-id copy in a sub-composition", async () => {
|
|
24
|
+
document.body.innerHTML =
|
|
25
|
+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card"></div></div>' +
|
|
26
|
+
'<div id="card" class="dot"></div>';
|
|
27
|
+
const element = document.querySelectorAll<HTMLElement>("#card")[1];
|
|
28
|
+
const selection = { id: "card", sourceFile: "index.html", element } as DomEditSelection;
|
|
29
|
+
const iframeRef = { current: { contentDocument: document } as unknown as HTMLIFrameElement };
|
|
30
|
+
let animations: GsapAnimation[] = [];
|
|
31
|
+
function Harness() {
|
|
32
|
+
animations = useGsapAnimationsForElement("p", "index.html", selection, 0, iframeRef).animations;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const root = mountReactHarness(<Harness />);
|
|
36
|
+
await act(async () => {});
|
|
37
|
+
expect(animations.map((animation) => animation.id)).toEqual(["dots"]);
|
|
38
|
+
act(() => root.unmount());
|
|
39
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
|
+
import { findPreviewNode } from "../components/editor/domEditingElement";
|
|
4
6
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
5
7
|
import {
|
|
6
8
|
clearKeyframeCacheForElement,
|
|
@@ -73,7 +75,7 @@ export function getAnimationsForElement(
|
|
|
73
75
|
export function useGsapAnimationsForElement(
|
|
74
76
|
projectId: string | null,
|
|
75
77
|
sourceFile: string,
|
|
76
|
-
target:
|
|
78
|
+
target: DomEditSelection | null,
|
|
77
79
|
version: number,
|
|
78
80
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
79
81
|
): {
|
|
@@ -157,24 +159,14 @@ export function useGsapAnimationsForElement(
|
|
|
157
159
|
// gsap.from(".dot", {stagger})) attribute to every matching element, not
|
|
158
160
|
// just the one whose exact selector equals the tween's. `version` re-runs
|
|
159
161
|
// this after composition reloads.
|
|
160
|
-
|
|
161
|
-
const doc = iframeRef?.current?.contentDocument;
|
|
162
|
-
if (doc) {
|
|
163
|
-
try {
|
|
164
|
-
element =
|
|
165
|
-
(targetId ? doc.getElementById(targetId) : null) ??
|
|
166
|
-
(targetSelector ? doc.querySelector(targetSelector) : null);
|
|
167
|
-
} catch {
|
|
168
|
-
element = null;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
162
|
+
const element = target ? findPreviewNode(iframeRef?.current?.contentDocument, target) : null;
|
|
171
163
|
return getAnimationsForElement(
|
|
172
164
|
allAnimations,
|
|
173
165
|
{ id: targetId, selector: targetSelector },
|
|
174
166
|
element,
|
|
175
167
|
);
|
|
176
168
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
|
-
}, [allAnimations,
|
|
169
|
+
}, [allAnimations, target, version, iframeRef]);
|
|
178
170
|
|
|
179
171
|
// fallow-ignore-next-line complexity
|
|
180
172
|
const animations = useMemo(() => {
|
|
@@ -33,6 +33,30 @@ beforeEach(() => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe("syncStoredAutomationFromPreview", () => {
|
|
36
|
+
it("reads the element the timeline bound, not an earlier same-id copy in a sub-composition", () => {
|
|
37
|
+
usePlayerStore.setState({ elements: [el({ hfId: "hf-root" })] });
|
|
38
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
39
|
+
doc.body.innerHTML =
|
|
40
|
+
'<div data-composition-id="strip"><audio id="bgm" data-hf-id="hf-inner"></audio></div>' +
|
|
41
|
+
'<audio id="bgm" data-hf-id="hf-root"></audio>';
|
|
42
|
+
doc.querySelector('[data-hf-id="hf-inner"]')?.setAttribute("data-automation", TWO_POINTS);
|
|
43
|
+
doc.querySelector('[data-hf-id="hf-root"]')?.setAttribute("data-automation", RESTORED);
|
|
44
|
+
syncStoredAutomationFromPreview(doc);
|
|
45
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reads the copy in the element's own file when a sub-composition repeats its hf-id", () => {
|
|
49
|
+
usePlayerStore.setState({ elements: [el({ hfId: "hf-bgm" })] });
|
|
50
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
51
|
+
doc.body.innerHTML =
|
|
52
|
+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
|
|
53
|
+
'<audio id="bgm" data-hf-id="hf-bgm"></audio></div><audio id="bgm" data-hf-id="hf-bgm"></audio>';
|
|
54
|
+
doc.querySelectorAll("audio")[0]?.setAttribute("data-automation", TWO_POINTS);
|
|
55
|
+
doc.querySelectorAll("audio")[1]?.setAttribute("data-automation", RESTORED);
|
|
56
|
+
syncStoredAutomationFromPreview(doc);
|
|
57
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
|
|
58
|
+
});
|
|
59
|
+
|
|
36
60
|
it("reads back an envelope an undo restored on the preview", () => {
|
|
37
61
|
// The bug: a soft undo patches the preview document and re-runs the timeline, but
|
|
38
62
|
// the store keeps its own copy of the attributes and that copy is what a lane
|
|
@@ -17,14 +17,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
|
17
17
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
18
18
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
19
19
|
import { groupInfoFor } from "./timelineGroupInfo";
|
|
20
|
-
|
|
21
|
-
/** The preview node an element stands for, by dom id and then by `data-hf-id`. */
|
|
22
|
-
function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
|
|
23
|
-
const domId = element.domId ?? element.id;
|
|
24
|
-
const byId = domId ? doc.getElementById(domId) : null;
|
|
25
|
-
if (byId) return byId;
|
|
26
|
-
return element.hfId ? doc.querySelector(`[data-hf-id="${element.hfId}"]`) : null;
|
|
27
|
-
}
|
|
20
|
+
import { previewElementFinder } from "./timelineElementHelpers";
|
|
28
21
|
|
|
29
22
|
/**
|
|
30
23
|
* Re-read every element's automation and FX-chain attributes from the preview
|
|
@@ -62,10 +55,11 @@ function syncedFields(doc: Document, element: TimelineElement, node: Element) {
|
|
|
62
55
|
|
|
63
56
|
export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
|
|
64
57
|
if (!doc) return;
|
|
58
|
+
const findNode = previewElementFinder(doc);
|
|
65
59
|
usePlayerStore.setState((state) => {
|
|
66
60
|
let changed = false;
|
|
67
61
|
const elements = state.elements.map((element) => {
|
|
68
|
-
const node =
|
|
62
|
+
const node = findNode(element);
|
|
69
63
|
if (!node) return element;
|
|
70
64
|
const fields = syncedFields(doc, element, node);
|
|
71
65
|
// Same array back when nothing moved: `elements` keys memos all over the
|
|
@@ -12,5 +12,5 @@ export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: num
|
|
|
12
12
|
if (!music || s.audioMuted) return;
|
|
13
13
|
const rel = nextTime - music.start;
|
|
14
14
|
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
|
|
15
|
-
scrubPreviewAudio(iframe, audioFileTime, music
|
|
15
|
+
scrubPreviewAudio(iframe, audioFileTime, music, s.audioVolume);
|
|
16
16
|
}
|
|
@@ -285,6 +285,34 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
|
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
+
it("keeps the host it was given when a sub-composition is mounted twice", () => {
|
|
289
|
+
const doc = makeDoc(`
|
|
290
|
+
<div data-composition-id="root" data-composition-file="index.html">
|
|
291
|
+
<div id="a" data-hf-id="hf-a" data-composition-id="card" data-composition-src="card.html"></div>
|
|
292
|
+
<div id="b" data-hf-id="hf-b" data-composition-id="card" data-composition-src="card.html"></div>
|
|
293
|
+
</div>
|
|
294
|
+
`);
|
|
295
|
+
const element = createTimelineElementFromManifestClip({
|
|
296
|
+
clip: {
|
|
297
|
+
id: "b",
|
|
298
|
+
label: "Card",
|
|
299
|
+
kind: "composition",
|
|
300
|
+
tagName: "div",
|
|
301
|
+
start: 4,
|
|
302
|
+
duration: 4,
|
|
303
|
+
track: 0,
|
|
304
|
+
compositionId: "card",
|
|
305
|
+
parentCompositionId: "root",
|
|
306
|
+
compositionSrc: null,
|
|
307
|
+
assetUrl: null,
|
|
308
|
+
},
|
|
309
|
+
fallbackIndex: 1,
|
|
310
|
+
doc,
|
|
311
|
+
hostEl: doc.getElementById("b"),
|
|
312
|
+
});
|
|
313
|
+
expect(element).toMatchObject({ domId: "b", hfId: "hf-b", compositionSrc: "card.html" });
|
|
314
|
+
});
|
|
315
|
+
|
|
288
316
|
it("ignores an index.html duplicate when indexing a scene.html selector", () => {
|
|
289
317
|
const doc = makeDoc(`
|
|
290
318
|
<div data-composition-id="root" data-composition-file="index.html">
|
|
@@ -171,8 +171,10 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
171
171
|
entry.playbackRate ??= 1;
|
|
172
172
|
let resolvedSrc = clip.compositionSrc;
|
|
173
173
|
if (!resolvedSrc) {
|
|
174
|
-
hostEl
|
|
175
|
-
|
|
174
|
+
if (hostEl?.getAttribute("data-composition-id") !== clip.compositionId) {
|
|
175
|
+
hostEl =
|
|
176
|
+
doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
|
|
177
|
+
}
|
|
176
178
|
resolvedSrc =
|
|
177
179
|
hostEl?.getAttribute("data-composition-src") ??
|
|
178
180
|
hostEl?.getAttribute("data-composition-file") ??
|
|
@@ -458,6 +458,39 @@ function findInClipScope(
|
|
|
458
458
|
return lone;
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
export type PreviewTarget = Pick<TimelineElement, "hfId" | "domId" | "id" | "sourceFile">;
|
|
462
|
+
|
|
463
|
+
/** Finds a row's preview element by `data-hf-id`, then id, preferring one in the row's own file: both repeat across
|
|
464
|
+
* files. Indexes the document once, so a pass over every row costs one scan. */
|
|
465
|
+
export function previewElementFinder(
|
|
466
|
+
doc: Document,
|
|
467
|
+
selector = "[data-hf-id], [id]",
|
|
468
|
+
): (target: PreviewTarget) => Element | null {
|
|
469
|
+
const byKey = new Map<string, Element[]>();
|
|
470
|
+
const add = (key: string, node: Element) => {
|
|
471
|
+
const nodes = byKey.get(key);
|
|
472
|
+
if (nodes) nodes.push(node);
|
|
473
|
+
else byKey.set(key, [node]);
|
|
474
|
+
};
|
|
475
|
+
for (const node of doc.querySelectorAll(selector)) {
|
|
476
|
+
const hfId = node.getAttribute("data-hf-id");
|
|
477
|
+
const id = node.getAttribute("id");
|
|
478
|
+
if (hfId) add(`hf:${hfId}`, node);
|
|
479
|
+
if (id) add(`id:${id}`, node);
|
|
480
|
+
}
|
|
481
|
+
return (target) => {
|
|
482
|
+
const matches = [
|
|
483
|
+
...((target.hfId && byKey.get(`hf:${target.hfId}`)) || []),
|
|
484
|
+
...(byKey.get(`id:${target.domId ?? target.id}`) ?? []),
|
|
485
|
+
];
|
|
486
|
+
return (
|
|
487
|
+
matches.find((node) => getTimelineElementSourceFile(node) === target.sourceFile) ??
|
|
488
|
+
matches[0] ??
|
|
489
|
+
null
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
461
494
|
export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {
|
|
462
495
|
if (!clip.id) return null;
|
|
463
496
|
const first = doc.getElementById(clip.id);
|
|
@@ -17,6 +17,31 @@ function makeDoc(html: string): Document {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
20
|
+
it("labels a row from its own host when a sub-composition repeats the host's id", () => {
|
|
21
|
+
const doc = makeDoc(`
|
|
22
|
+
<div data-composition-id="main">
|
|
23
|
+
<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="scene"></div></div>
|
|
24
|
+
<div id="scene" data-hf-id="hf-scene" data-composition-id="scene" data-composition-file="compositions/scene.html"></div>
|
|
25
|
+
</div>
|
|
26
|
+
`);
|
|
27
|
+
const row = {
|
|
28
|
+
id: "scene",
|
|
29
|
+
key: "scene",
|
|
30
|
+
tag: "div",
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: 4,
|
|
33
|
+
track: 0,
|
|
34
|
+
hfId: "hf-scene",
|
|
35
|
+
};
|
|
36
|
+
const { updatedEls, patched } = buildMissingCompositionElements(
|
|
37
|
+
doc,
|
|
38
|
+
window as IframeWindow,
|
|
39
|
+
[row],
|
|
40
|
+
10,
|
|
41
|
+
);
|
|
42
|
+
expect([patched, updatedEls[0]?.compositionSrc]).toEqual([true, "compositions/scene.html"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
20
45
|
it("harvests hfId from data-hf-id on composition host elements", () => {
|
|
21
46
|
const doc = makeDoc(`
|
|
22
47
|
<div data-composition-id="root">
|
|
@@ -104,7 +129,7 @@ describe("scrubPreviewAudio", () => {
|
|
|
104
129
|
audio.pause = vi.fn();
|
|
105
130
|
iframe.contentDocument.body.append(audio);
|
|
106
131
|
|
|
107
|
-
scrubPreviewAudio(iframe, 0.5, "music", 0.4);
|
|
132
|
+
scrubPreviewAudio(iframe, 0.5, { id: "music" }, 0.4);
|
|
108
133
|
|
|
109
134
|
expect(audio.volume).toBeCloseTo(0.1);
|
|
110
135
|
stopScrubPreviewAudio();
|
|
@@ -112,13 +137,13 @@ describe("scrubPreviewAudio", () => {
|
|
|
112
137
|
|
|
113
138
|
/**
|
|
114
139
|
* The preview document is a different realm, so `instanceof HTMLAudioElement`
|
|
115
|
-
* is false for every node in it. That threw the `
|
|
140
|
+
* is false for every node in it. That threw the `music` hint away and left
|
|
116
141
|
* the first `<audio>` in the document as the only route — and the first
|
|
117
142
|
* `<audio>` is often the voiceover, so scrubbing previewed the wrong track.
|
|
118
143
|
* Two elements, music second, is what tells the two paths apart: with one
|
|
119
144
|
* element the fallback reaches the right node by accident.
|
|
120
145
|
*/
|
|
121
|
-
it("previews the track named by
|
|
146
|
+
it("previews the track named by the music row, not the first audio in the document", () => {
|
|
122
147
|
const iframe = document.createElement("iframe");
|
|
123
148
|
document.body.append(iframe);
|
|
124
149
|
const previewDoc = iframe.contentDocument;
|
|
@@ -139,13 +164,32 @@ describe("scrubPreviewAudio", () => {
|
|
|
139
164
|
// The node really is cross-realm; this is the condition, not a contrivance.
|
|
140
165
|
expect(music instanceof HTMLAudioElement).toBe(false);
|
|
141
166
|
|
|
142
|
-
scrubPreviewAudio(iframe, 0.5, "music-bed", 1);
|
|
167
|
+
scrubPreviewAudio(iframe, 0.5, { id: "music-bed" }, 1);
|
|
143
168
|
|
|
144
169
|
expect(music.play).toHaveBeenCalled();
|
|
145
170
|
expect(voiceover.play).not.toHaveBeenCalled();
|
|
146
171
|
stopScrubPreviewAudio();
|
|
147
172
|
});
|
|
148
173
|
|
|
174
|
+
it("previews the music row's own track when a sub-composition repeats its id", () => {
|
|
175
|
+
const iframe = document.createElement("iframe");
|
|
176
|
+
document.body.append(iframe);
|
|
177
|
+
const previewDoc = iframe.contentDocument;
|
|
178
|
+
if (!previewDoc?.body) throw new Error("expected an iframe document");
|
|
179
|
+
previewDoc.body.innerHTML =
|
|
180
|
+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html"><audio id="music"></audio></div>' +
|
|
181
|
+
'<audio id="music" class="root"></audio>';
|
|
182
|
+
const [inner, root] = Array.from(previewDoc.querySelectorAll("audio"));
|
|
183
|
+
for (const audio of [inner, root])
|
|
184
|
+
Object.assign(audio!, { play: vi.fn(async () => {}), pause: vi.fn() });
|
|
185
|
+
|
|
186
|
+
scrubPreviewAudio(iframe, 0.5, { id: "music" }, 1);
|
|
187
|
+
|
|
188
|
+
expect(root!.play).toHaveBeenCalled();
|
|
189
|
+
expect(inner!.play).not.toHaveBeenCalled();
|
|
190
|
+
stopScrubPreviewAudio();
|
|
191
|
+
});
|
|
192
|
+
|
|
149
193
|
/** A scrub audition is media running under a paused clock, which the runtime now
|
|
150
194
|
* stops on sight. So it borrows the element. That a leased element survives the
|
|
151
195
|
* tick is asserted runtime-side in core's `transportPark.test.ts`; here the
|
|
@@ -166,7 +210,7 @@ describe("scrubPreviewAudio", () => {
|
|
|
166
210
|
const releasePausedMedia = vi.fn();
|
|
167
211
|
(previewDoc.defaultView as IframeWindow).__hf = { leasePausedMedia, releasePausedMedia };
|
|
168
212
|
|
|
169
|
-
scrubPreviewAudio(iframe, 0.5, "music", 1);
|
|
213
|
+
scrubPreviewAudio(iframe, 0.5, { id: "music" }, 1);
|
|
170
214
|
|
|
171
215
|
expect(leasePausedMedia).toHaveBeenCalledWith(music);
|
|
172
216
|
expect(releasePausedMedia).not.toHaveBeenCalled();
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
getTimelineElementDisplayLabel,
|
|
21
21
|
buildTimelineElementIdentity,
|
|
22
22
|
readTimelineElementZIndex,
|
|
23
|
+
previewElementFinder,
|
|
24
|
+
type PreviewTarget,
|
|
23
25
|
} from "./timelineElementHelpers";
|
|
24
26
|
import { postRuntimeControlMessage } from "./runtimeProtocol";
|
|
25
27
|
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
|
|
@@ -216,14 +218,14 @@ let scrubStopTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
216
218
|
let scrubPrevMuted: boolean | null = null;
|
|
217
219
|
let scrubPrevVolume: number | null = null;
|
|
218
220
|
|
|
219
|
-
// Resolve the SAME element the store identified as music: prefer
|
|
220
|
-
// the role attribute, and only fall back to the first <audio>
|
|
221
|
-
// voiceover, so the
|
|
221
|
+
// Resolve the SAME element the store identified as music: prefer that row's own
|
|
222
|
+
// element, then the role attribute, and only fall back to the first <audio>
|
|
223
|
+
// (which could be a voiceover, so the row hint matters).
|
|
222
224
|
/**
|
|
223
225
|
* `doc` is the preview iframe's document, so its `<audio>` nodes are instances of
|
|
224
226
|
* the IFRAME's `HTMLAudioElement`, never this module's. `instanceof
|
|
225
227
|
* HTMLAudioElement` here is false for every one of them, which silently threw the
|
|
226
|
-
* `
|
|
228
|
+
* `music` hint away and fell through to "first `<audio>` in the document" — the
|
|
227
229
|
* very thing the comment above warns can be a voiceover. Ask what the node IS.
|
|
228
230
|
* Same rule and same reasoning as packages/core/src/runtime/domRealm.ts.
|
|
229
231
|
*/
|
|
@@ -235,9 +237,9 @@ function isAudioNode(node: Element | null): node is HTMLAudioElement {
|
|
|
235
237
|
);
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
function resolveScrubAudioEl(doc: Document,
|
|
239
|
-
if (
|
|
240
|
-
const byId = doc
|
|
240
|
+
function resolveScrubAudioEl(doc: Document, music?: PreviewTarget | null): HTMLAudioElement | null {
|
|
241
|
+
if (music) {
|
|
242
|
+
const byId = previewElementFinder(doc, "audio")(music);
|
|
241
243
|
if (isAudioNode(byId)) return byId;
|
|
242
244
|
}
|
|
243
245
|
return (
|
|
@@ -283,7 +285,7 @@ function applyScrub(el: HTMLAudioElement, audioFileTime: number, previewVolume:
|
|
|
283
285
|
export function scrubPreviewAudio(
|
|
284
286
|
iframe: HTMLIFrameElement | null,
|
|
285
287
|
audioFileTime: number | null,
|
|
286
|
-
|
|
288
|
+
music?: PreviewTarget | null,
|
|
287
289
|
previewVolume = 1,
|
|
288
290
|
): void {
|
|
289
291
|
if (!iframe) return;
|
|
@@ -298,7 +300,7 @@ export function scrubPreviewAudio(
|
|
|
298
300
|
return;
|
|
299
301
|
}
|
|
300
302
|
if (!doc) return;
|
|
301
|
-
const el = resolveScrubAudioEl(doc,
|
|
303
|
+
const el = resolveScrubAudioEl(doc, music);
|
|
302
304
|
if (el) applyScrub(el, audioFileTime, previewVolume);
|
|
303
305
|
}
|
|
304
306
|
|
|
@@ -512,12 +514,11 @@ export function buildMissingCompositionElements(
|
|
|
512
514
|
|
|
513
515
|
// Patch existing elements that are missing compositionSrc
|
|
514
516
|
let patched = false;
|
|
517
|
+
const findHost = previewElementFinder(doc);
|
|
515
518
|
const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
|
|
516
519
|
if (existing.compositionSrc) return existing;
|
|
517
|
-
// Find the matching DOM host by element id or composition id
|
|
518
520
|
const host =
|
|
519
|
-
doc.
|
|
520
|
-
doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
|
|
521
|
+
findHost(existing) ?? doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
|
|
521
522
|
if (!host) return existing;
|
|
522
523
|
const compSrc =
|
|
523
524
|
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
@@ -23,14 +23,12 @@ describe("studio UI preferences", () => {
|
|
|
23
23
|
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
|
|
24
24
|
writeStudioUiPreferences({ audioMuted: true }, storage);
|
|
25
25
|
writeStudioUiPreferences({ audioVolume: 0.4 }, storage);
|
|
26
|
-
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
|
|
27
26
|
|
|
28
27
|
expect(readStudioUiPreferences(storage)).toEqual({
|
|
29
28
|
timelineVisible: false,
|
|
30
29
|
playbackRate: 1.5,
|
|
31
30
|
audioMuted: true,
|
|
32
31
|
audioVolume: 0.4,
|
|
33
|
-
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
|
|
34
32
|
});
|
|
35
33
|
});
|
|
36
34
|
|
|
@@ -49,6 +47,15 @@ describe("studio UI preferences", () => {
|
|
|
49
47
|
expect(readStudioUiPreferences(storage)).toEqual({});
|
|
50
48
|
});
|
|
51
49
|
|
|
50
|
+
it("keeps no preview zoom, so every project opens at Fit", () => {
|
|
51
|
+
const storage = createStorage();
|
|
52
|
+
storage.setItem(
|
|
53
|
+
"hf-studio-ui-preferences",
|
|
54
|
+
JSON.stringify({ previewZoom: { zoomPercent: 245, panX: 0, panY: 0 } }),
|
|
55
|
+
);
|
|
56
|
+
expect(readStudioUiPreferences(storage)).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
52
59
|
it("ignores malformed stored values", () => {
|
|
53
60
|
const storage = createStorage();
|
|
54
61
|
storage.setItem(
|
|
@@ -58,7 +65,6 @@ describe("studio UI preferences", () => {
|
|
|
58
65
|
playbackRate: Number.NaN,
|
|
59
66
|
audioMuted: "false",
|
|
60
67
|
audioVolume: 2,
|
|
61
|
-
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
|
|
62
68
|
}),
|
|
63
69
|
);
|
|
64
70
|
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { SerializedDockview } from "dockview-react";
|
|
2
2
|
import { parseDockLayout } from "../components/dock/dockLayoutSchema";
|
|
3
3
|
|
|
4
|
-
export interface StoredPreviewZoomState {
|
|
5
|
-
zoomPercent: number;
|
|
6
|
-
panX: number;
|
|
7
|
-
panY: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
4
|
export type TimelineTimeDisplayMode = "time" | "frame";
|
|
11
5
|
|
|
12
6
|
export interface StudioUiPreferences {
|
|
@@ -15,7 +9,6 @@ export interface StudioUiPreferences {
|
|
|
15
9
|
audioMuted?: boolean;
|
|
16
10
|
audioVolume?: number;
|
|
17
11
|
thumbnailMode?: "adaptive" | "hidden";
|
|
18
|
-
previewZoom?: StoredPreviewZoomState;
|
|
19
12
|
recentBlocks?: string[];
|
|
20
13
|
snapEnabled?: boolean;
|
|
21
14
|
gridVisible?: boolean;
|
|
@@ -103,19 +96,6 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
|
|
|
103
96
|
} else if (typeof parsed.thumbnailsEnabled === "boolean") {
|
|
104
97
|
preferences.thumbnailMode = parsed.thumbnailsEnabled ? "adaptive" : "hidden";
|
|
105
98
|
}
|
|
106
|
-
if (isRecord(parsed.previewZoom)) {
|
|
107
|
-
const { zoomPercent, panX, panY } = parsed.previewZoom;
|
|
108
|
-
if (
|
|
109
|
-
typeof zoomPercent === "number" &&
|
|
110
|
-
Number.isFinite(zoomPercent) &&
|
|
111
|
-
typeof panX === "number" &&
|
|
112
|
-
Number.isFinite(panX) &&
|
|
113
|
-
typeof panY === "number" &&
|
|
114
|
-
Number.isFinite(panY)
|
|
115
|
-
) {
|
|
116
|
-
preferences.previewZoom = { zoomPercent, panX, panY };
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
99
|
if (Array.isArray(parsed.recentBlocks)) {
|
|
120
100
|
preferences.recentBlocks = parsed.recentBlocks.filter(
|
|
121
101
|
(v: unknown): v is string => typeof v === "string",
|
|
File without changes
|