Skip to content

Commit 276ded9

Browse files
committed
fix(studio): scrub audio, panel reads and gestures act on the bound element, one scan per pass
1 parent 9a2ce03 commit 276ded9

10 files changed

Lines changed: 112 additions & 58 deletions

‎packages/studio/src/components/editor/gsapLivePreview.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { expect, it, vi } from "vitest";
33
import { createGsapLivePreview } from "./gsapLivePreview";
44
import type { DomEditSelection } from "./domEditingTypes";
5+
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6+
import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers";
57

68
it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => {
79
document.body.innerHTML =
@@ -24,3 +26,38 @@ it("previews on the copy in the selection's own file when a sub-composition repe
2426
preview({ id: "card", hfId: "hf-card", sourceFile: "index.html" } as DomEditSelection, { x: 10 });
2527
expect(set.mock.calls[0]?.[0]).toBe(document.querySelector(".root"));
2628
});
29+
30+
it("previews on the selected node while it is mounted, even in a second mount of the same sub-composition", () => {
31+
document.body.innerHTML =
32+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card" data-hf-id="hf-card"></div></div>' +
33+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card" data-hf-id="hf-card"></div></div>';
34+
const second = document.querySelectorAll<HTMLElement>("#card")[1];
35+
const selection = {
36+
id: "card",
37+
hfId: "hf-card",
38+
sourceFile: "compositions/strip.html",
39+
element: second,
40+
};
41+
const set = vi.fn();
42+
const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
43+
createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement })(
44+
selection as DomEditSelection,
45+
{ x: 10 },
46+
);
47+
expect(set.mock.calls[0]?.[0]).toBe(second);
48+
});
49+
50+
it("the panel reads GSAP values off the node the live preview moves", () => {
51+
document.body.innerHTML =
52+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card"></div></div>' +
53+
'<div id="card" class="root"></div>';
54+
const root = document.querySelector<HTMLElement>(".root");
55+
const getProperty = vi.fn(() => 5);
56+
const iframe = { contentWindow: { gsap: { getProperty } }, contentDocument: document };
57+
const selection = { id: "card", sourceFile: "index.html", element: root } as DomEditSelection;
58+
const animations = [{ properties: { x: 5 } }] as unknown as GsapAnimation[];
59+
readGsapRuntimeValuesForPanel("anim", animations, selection, {
60+
current: iframe as unknown as HTMLIFrameElement,
61+
});
62+
expect(getProperty.mock.calls[0]?.[0]).toBe(root);
63+
});

‎packages/studio/src/components/editor/gsapLivePreview.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { findElementForSelection } from "./domEditingElement";
99
* Extracted so the identical closure exists once — shared by the legacy
1010
* PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
1111
*/
12-
// hf-ids and ids repeat across flattened sub-compositions and a selector's first match may be a sibling, so look in
13-
// the selection's own file first, then anywhere.
14-
function resolvePreviewNode(
12+
// The selected node while it is still mounted; after a reload, the copy in the selection's own file (hf-ids and ids
13+
// repeat across flattened sub-compositions), then anywhere.
14+
export function findPreviewNode(
1515
doc: Document | null | undefined,
1616
el: DomEditSelection,
1717
): Element | null {
1818
if (!doc) return null;
19+
if (el.element?.isConnected && el.element.ownerDocument === doc) return el.element;
1920
return (
2021
findElementForSelection(doc, el) ??
2122
findElementForSelection(doc, { ...el, sourceFile: undefined })
@@ -29,7 +30,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE
2930
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
3031
| null
3132
| undefined;
32-
const node = resolvePreviewNode(iframe?.contentDocument, el);
33+
const node = findPreviewNode(iframe?.contentDocument, el);
3334
if (win?.gsap && node) win.gsap.set(node, props);
3435
};
3536
}

‎packages/studio/src/components/editor/propertyPanelHelpers.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "./domEditing";
44
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
55
import type { TimelineElement } from "../../player";
66
import { roundToCenti } from "../../utils/rounding";
7+
import { findPreviewNode } from "./gsapLivePreview";
78

89
export type {
910
BackgroundRemovalProgress,
@@ -470,16 +471,14 @@ export function readGsapRuntimeValuesForPanel(
470471
if (!gsapAnimId || gsapAnimations.length === 0) return null;
471472
const iframe = previewIframeRef?.current;
472473
if (!iframe?.contentWindow) return null;
473-
const selector = element.id ? `#${element.id}` : element.selector;
474-
if (!selector) return null;
475474
try {
476475
const gsap = (
477476
iframe.contentWindow as unknown as {
478477
gsap?: { getProperty: (el: Element, prop: string) => number | string };
479478
}
480479
).gsap;
481480
if (!gsap?.getProperty) return null;
482-
const el = iframe.contentDocument?.querySelector(selector);
481+
const el = findPreviewNode(iframe.contentDocument, element);
483482
if (!el) return null;
484483
const propKeys = collectPanelPropKeys(gsapAnimations);
485484
const result: Record<string, number> = {};
@@ -508,10 +507,9 @@ export function readGsapBorderRadiusForPanel(
508507
if (!hasBRProp) return null;
509508
}
510509
const iframe = previewIframeRef?.current;
511-
const selector = element.id ? `#${element.id}` : element.selector;
512-
if (!iframe?.contentDocument || !selector) return null;
510+
if (!iframe?.contentDocument) return null;
513511
try {
514-
const el = iframe.contentDocument.querySelector(selector);
512+
const el = findPreviewNode(iframe.contentDocument, element);
515513
if (!el || !iframe.contentWindow) return null;
516514
const cs = iframe.contentWindow.getComputedStyle(el);
517515
const parse = (v: string) => Number.parseFloat(v) || 0;

‎packages/studio/src/hooks/useGestureRecording.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe("useGestureRecording", () => {
109109
act(() => root.unmount());
110110

111111
expect(cancelAnimationFrame).toHaveBeenCalledWith(17);
112-
expect(set).toHaveBeenCalledWith("#card", {
112+
expect(set).toHaveBeenCalledWith(element, {
113113
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
114114
});
115115
expect(element.style.visibility).toBe("hidden");

‎packages/studio/src/hooks/useGestureRecording.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ interface BasePosition {
3030

3131
interface GsapRuntime {
3232
timeline: { seek: (t: number) => void };
33-
gsap: { set: (target: string, vars: Record<string, number | string>) => void };
34-
selector: string;
33+
gsap: { set: (target: Element, vars: Record<string, number | string>) => void };
3534
element: HTMLElement;
3635
startTime: number;
3736
maxSeekTime: number;
@@ -86,7 +85,7 @@ function connectGsapRuntime(
8685
): GsapRuntime | null {
8786
try {
8887
const win = iframeEl.contentWindow as Window & {
89-
gsap?: { set: (t: string, v: Record<string, number | string>) => void };
88+
gsap?: { set: (t: Element, v: Record<string, number | string>) => void };
9089
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
9190
__player?: { getTime: () => number };
9291
};
@@ -103,7 +102,6 @@ function connectGsapRuntime(
103102
return {
104103
timeline: tl,
105104
gsap: win.gsap,
106-
selector,
107105
element,
108106
startTime: win.__player?.getTime() ?? 0,
109107
maxSeekTime:
@@ -126,7 +124,7 @@ function applyRuntimePreview(
126124
const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
127125
runtime.timeline.seek(seekTime);
128126
runtime.element.style.setProperty("translate", "none");
129-
runtime.gsap.set(runtime.selector, { ...properties });
127+
runtime.gsap.set(runtime.element, { ...properties });
130128
runtime.element.style.visibility = "visible";
131129
liveTime.notify(seekTime);
132130
usePlayerStore.getState().setCurrentTime(seekTime);
@@ -256,7 +254,7 @@ function releaseRuntimePreview(r: RecordingRefs): void {
256254
element.style.visibility = savedVisibility;
257255
element.style.setProperty("translate", savedTranslate || "");
258256
try {
259-
runtime.gsap.set(runtime.selector, {
257+
runtime.gsap.set(runtime.element, {
260258
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
261259
});
262260
} catch {

‎packages/studio/src/player/lib/automationStoreSync.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
1717
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
1818
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
1919
import { groupInfoFor } from "./timelineGroupInfo";
20-
import { findPreviewElement } from "./timelineElementHelpers";
20+
import { previewElementFinder } from "./timelineElementHelpers";
2121

2222
/**
2323
* Re-read every element's automation and FX-chain attributes from the preview
@@ -55,14 +55,11 @@ function syncedFields(doc: Document, element: TimelineElement, node: Element) {
5555

5656
export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
5757
if (!doc) return;
58+
const findNode = previewElementFinder(doc);
5859
usePlayerStore.setState((state) => {
5960
let changed = false;
6061
const elements = state.elements.map((element) => {
61-
const node = findPreviewElement(doc, {
62-
hfId: element.hfId,
63-
id: element.domId ?? element.id,
64-
sourceFile: element.sourceFile,
65-
});
62+
const node = findNode(element);
6663
if (!node) return element;
6764
const fields = syncedFields(doc, element, node);
6865
// Same array back when nothing moved: `elements` keys memos all over the

‎packages/studio/src/player/lib/playbackScrub.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: num
1212
if (!music || s.audioMuted) return;
1313
const rel = nextTime - music.start;
1414
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
15-
scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id, s.audioVolume);
15+
scrubPreviewAudio(iframe, audioFileTime, music, s.audioVolume);
1616
}

‎packages/studio/src/player/lib/timelineElementHelpers.ts‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -458,23 +458,29 @@ function findInClipScope(
458458
return lone;
459459
}
460460

461-
/** A preview element by `data-hf-id` then id, which both repeat across files, preferring one in its own file. */
462-
export function findPreviewElement(
463-
doc: Document,
464-
target: { hfId?: string; id?: string | null; sourceFile?: string },
465-
): Element | null {
466-
const selectors = [
467-
target.hfId && `[data-hf-id="${CSS.escape(target.hfId)}"]`,
468-
target.id && `[id="${CSS.escape(target.id)}"]`,
469-
];
470-
const matches = selectors.flatMap((selector) =>
471-
selector ? Array.from(doc.querySelectorAll(selector)) : [],
472-
);
473-
return (
474-
matches.find((node) => getTimelineElementSourceFile(node) === target.sourceFile) ??
475-
matches[0] ??
476-
null
477-
);
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(doc: Document): (target: PreviewTarget) => Element | null {
466+
const byKey = new Map<string, Element[]>();
467+
const add = (key: string, node: Element) => byKey.set(key, [...(byKey.get(key) ?? []), node]);
468+
for (const node of doc.querySelectorAll("[data-hf-id], [id]")) {
469+
const hfId = node.getAttribute("data-hf-id");
470+
if (hfId) add(`hf:${hfId}`, node);
471+
if (node.id) add(`id:${node.id}`, node);
472+
}
473+
return (target) => {
474+
const matches = [
475+
...((target.hfId && byKey.get(`hf:${target.hfId}`)) || []),
476+
...(byKey.get(`id:${target.domId ?? target.id}`) ?? []),
477+
];
478+
return (
479+
matches.find((node) => getTimelineElementSourceFile(node) === target.sourceFile) ??
480+
matches[0] ??
481+
null
482+
);
483+
};
478484
}
479485

480486
export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {

‎packages/studio/src/player/lib/timelineIframeHelpers.test.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,21 @@ describe("scrubPreviewAudio", () => {
130130
audio.pause = vi.fn();
131131
iframe.contentDocument.body.append(audio);
132132

133-
scrubPreviewAudio(iframe, 0.5, "music", 0.4);
133+
scrubPreviewAudio(iframe, 0.5, { id: "music" }, 0.4);
134134

135135
expect(audio.volume).toBeCloseTo(0.1);
136136
stopScrubPreviewAudio();
137137
});
138138

139139
/**
140140
* The preview document is a different realm, so `instanceof HTMLAudioElement`
141-
* is false for every node in it. That threw the `musicId` hint away and left
141+
* is false for every node in it. That threw the `music` hint away and left
142142
* the first `<audio>` in the document as the only route — and the first
143143
* `<audio>` is often the voiceover, so scrubbing previewed the wrong track.
144144
* Two elements, music second, is what tells the two paths apart: with one
145145
* element the fallback reaches the right node by accident.
146146
*/
147-
it("previews the track named by musicId, not the first audio in the document", () => {
147+
it("previews the track named by the music row, not the first audio in the document", () => {
148148
const iframe = document.createElement("iframe");
149149
document.body.append(iframe);
150150
const previewDoc = iframe.contentDocument;
@@ -165,13 +165,32 @@ describe("scrubPreviewAudio", () => {
165165
// The node really is cross-realm; this is the condition, not a contrivance.
166166
expect(music instanceof HTMLAudioElement).toBe(false);
167167

168-
scrubPreviewAudio(iframe, 0.5, "music-bed", 1);
168+
scrubPreviewAudio(iframe, 0.5, { id: "music-bed" }, 1);
169169

170170
expect(music.play).toHaveBeenCalled();
171171
expect(voiceover.play).not.toHaveBeenCalled();
172172
stopScrubPreviewAudio();
173173
});
174174

175+
it("previews the music row's own track when a sub-composition repeats its id", () => {
176+
const iframe = document.createElement("iframe");
177+
document.body.append(iframe);
178+
const previewDoc = iframe.contentDocument;
179+
if (!previewDoc?.body) throw new Error("expected an iframe document");
180+
previewDoc.body.innerHTML =
181+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html"><audio id="music"></audio></div>' +
182+
'<audio id="music" class="root"></audio>';
183+
const [inner, root] = Array.from(previewDoc.querySelectorAll("audio"));
184+
for (const audio of [inner, root])
185+
Object.assign(audio!, { play: vi.fn(async () => {}), pause: vi.fn() });
186+
187+
scrubPreviewAudio(iframe, 0.5, { id: "music" }, 1);
188+
189+
expect(root!.play).toHaveBeenCalled();
190+
expect(inner!.play).not.toHaveBeenCalled();
191+
stopScrubPreviewAudio();
192+
});
193+
175194
/** A scrub audition is media running under a paused clock, which the runtime now
176195
* stops on sight. So it borrows the element. That a leased element survives the
177196
* tick is asserted runtime-side in core's `transportPark.test.ts`; here the
@@ -192,7 +211,7 @@ describe("scrubPreviewAudio", () => {
192211
const releasePausedMedia = vi.fn();
193212
(previewDoc.defaultView as IframeWindow).__hf = { leasePausedMedia, releasePausedMedia };
194213

195-
scrubPreviewAudio(iframe, 0.5, "music", 1);
214+
scrubPreviewAudio(iframe, 0.5, { id: "music" }, 1);
196215

197216
expect(leasePausedMedia).toHaveBeenCalledWith(music);
198217
expect(releasePausedMedia).not.toHaveBeenCalled();

‎packages/studio/src/player/lib/timelineIframeHelpers.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
getTimelineElementDisplayLabel,
2121
buildTimelineElementIdentity,
2222
readTimelineElementZIndex,
23-
findPreviewElement,
23+
previewElementFinder,
24+
type PreviewTarget,
2425
} from "./timelineElementHelpers";
2526
import { postRuntimeControlMessage } from "./runtimeProtocol";
2627
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
@@ -224,7 +225,7 @@ let scrubPrevVolume: number | null = null;
224225
* `doc` is the preview iframe's document, so its `<audio>` nodes are instances of
225226
* the IFRAME's `HTMLAudioElement`, never this module's. `instanceof
226227
* HTMLAudioElement` here is false for every one of them, which silently threw the
227-
* `musicId` hint away and fell through to "first `<audio>` in the document" — the
228+
* `music` hint away and fell through to "first `<audio>` in the document" — the
228229
* very thing the comment above warns can be a voiceover. Ask what the node IS.
229230
* Same rule and same reasoning as packages/core/src/runtime/domRealm.ts.
230231
*/
@@ -236,9 +237,9 @@ function isAudioNode(node: Element | null): node is HTMLAudioElement {
236237
);
237238
}
238239

239-
function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioElement | null {
240-
if (musicId) {
241-
const byId = doc.getElementById(musicId);
240+
function resolveScrubAudioEl(doc: Document, music?: PreviewTarget | null): HTMLAudioElement | null {
241+
if (music) {
242+
const byId = previewElementFinder(doc)(music);
242243
if (isAudioNode(byId)) return byId;
243244
}
244245
return (
@@ -284,7 +285,7 @@ function applyScrub(el: HTMLAudioElement, audioFileTime: number, previewVolume:
284285
export function scrubPreviewAudio(
285286
iframe: HTMLIFrameElement | null,
286287
audioFileTime: number | null,
287-
musicId?: string | null,
288+
music?: PreviewTarget | null,
288289
previewVolume = 1,
289290
): void {
290291
if (!iframe) return;
@@ -299,7 +300,7 @@ export function scrubPreviewAudio(
299300
return;
300301
}
301302
if (!doc) return;
302-
const el = resolveScrubAudioEl(doc, musicId);
303+
const el = resolveScrubAudioEl(doc, music);
303304
if (el) applyScrub(el, audioFileTime, previewVolume);
304305
}
305306

@@ -513,14 +514,11 @@ export function buildMissingCompositionElements(
513514

514515
// Patch existing elements that are missing compositionSrc
515516
let patched = false;
517+
const findHost = previewElementFinder(doc);
516518
const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
517519
if (existing.compositionSrc) return existing;
518520
const host =
519-
findPreviewElement(doc, {
520-
hfId: existing.hfId,
521-
id: existing.domId ?? existing.id,
522-
sourceFile: existing.sourceFile,
523-
}) ?? doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
521+
findHost(existing) ?? doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
524522
if (!host) return existing;
525523
const compSrc =
526524
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");

0 commit comments

Comments
 (0)