@hyperframes/studio 0.8.116 → 0.8.118

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.
Files changed (58) hide show
  1. package/dist/assets/{hyperframes-player-mGYvPVpw.js → hyperframes-player-CnpIuNRl.js} +1 -1
  2. package/dist/assets/{index-DYNaXPVf.js → index-B4SbAADe.js} +196 -194
  3. package/dist/assets/{index-Dr3hNpvS.js → index-BZ23-h1_.js} +1 -1
  4. package/dist/assets/{index-BWHt0-ir.js → index-FeRU6G3G.js} +1 -1
  5. package/dist/index.d.ts +29 -19
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2366 -2305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/StudioRightPanels.tsx +3 -6
  11. package/src/components/TimelineToolbar.test.tsx +14 -0
  12. package/src/components/TimelineToolbar.tsx +4 -1
  13. package/src/components/editor/DomEditOverlay.tsx +1 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +149 -25
  15. package/src/components/editor/PropertyPanel.tsx +1 -12
  16. package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
  17. package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
  18. package/src/components/editor/PropertyPanelFlat.tsx +9 -22
  19. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
  20. package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
  21. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  23. package/src/components/editor/manualOffsetDrag.ts +3 -3
  24. package/src/components/editor/plainTranslate.ts +5 -3
  25. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  26. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  27. package/src/components/editor/rotationDraft.ts +2 -1
  28. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  29. package/src/components/editor/usePanelHiddenToggle.ts +44 -0
  30. package/src/contexts/DomEditContext.tsx +3 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  32. package/src/hooks/gsapScriptCommitTypes.ts +1 -1
  33. package/src/hooks/plainRotation.ts +5 -1
  34. package/src/hooks/timelineTrackVisibility.ts +7 -17
  35. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  36. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  37. package/src/hooks/useDomGeometryCommit.ts +6 -9
  38. package/src/hooks/useFreezeFrame.test.ts +39 -3
  39. package/src/hooks/useFreezeFrame.ts +4 -1
  40. package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
  41. package/src/hooks/useGsapAwareEditing.ts +12 -8
  42. package/src/hooks/useGsapAwareGroupMove.ts +2 -2
  43. package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
  44. package/src/hooks/useGsapScriptCommits.ts +9 -7
  45. package/src/hooks/usePersistentEditHistory.test.ts +25 -2
  46. package/src/hooks/usePersistentEditHistory.ts +3 -9
  47. package/src/player/components/Timeline.test.ts +48 -0
  48. package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
  49. package/src/player/components/TimelineTrackHeader.tsx +4 -2
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
  52. package/src/player/components/hiddenToggle.tsx +20 -0
  53. package/src/utils/gsapSoftReload.test.ts +51 -2
  54. package/src/utils/gsapSoftReload.ts +11 -21
  55. package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
  56. package/src/utils/softReloadTargets.ts +4 -3
  57. package/src/utils/studioFileHistory.ts +1 -0
  58. package/src/utils/timelineInspector.ts +10 -5
@@ -1,39 +1,28 @@
1
1
  import type React from "react";
2
- import { Eye, EyeSlash } from "@phosphor-icons/react";
3
2
  import { Music } from "../../icons/SystemIcons";
4
3
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
5
4
  import { TrackClipCount } from "./TrackClipCount";
6
5
  import { trackDisplaySuffix } from "./timelineTrackDisplay";
7
-
8
- // Hide, plainly. The speaker variant was the mute presentation; with mute gone
9
- // this is the visibility eye it always was, and audio rows do not render it.
10
- function visibilityButtonLabel(hidden: boolean, suffix: string): string {
11
- return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
12
- }
13
-
14
- function visibilityButtonIcon(hidden: boolean) {
15
- const Icon = hidden ? EyeSlash : Eye;
16
- return <Icon size={14} weight="bold" aria-hidden="true" />;
17
- }
6
+ import { HiddenToggleIcon, hiddenToggleVerb } from "./hiddenToggle";
18
7
 
19
8
  export function VisibilityButton({
20
9
  hidden,
21
10
  trackNumber,
22
11
  trackDisplayNumber,
23
- visible,
12
+ asMute,
24
13
  onToggle,
25
14
  }: {
26
15
  hidden: boolean;
27
16
  trackNumber: number;
28
17
  trackDisplayNumber: number | null;
29
- visible: boolean;
18
+ /** `data-hidden` silences an audio-only track, so its toggle is a mute. */
19
+ asMute: boolean;
30
20
  onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
31
21
  }) {
32
- if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
33
22
  // Display number in the text, real key in the callback. The two must not be
34
23
  // conflated in either direction.
35
24
  const suffix = trackDisplaySuffix(trackDisplayNumber);
36
- const label = visibilityButtonLabel(hidden, suffix);
25
+ const label = `${hiddenToggleVerb(asMute, hidden)} track${suffix}`;
37
26
  return (
38
27
  <button
39
28
  type="button"
@@ -52,7 +41,7 @@ export function VisibilityButton({
52
41
  void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
53
42
  }}
54
43
  >
55
- {visibilityButtonIcon(hidden)}
44
+ <HiddenToggleIcon asMute={asMute} hidden={hidden} size={14} />
56
45
  </button>
57
46
  );
58
47
  }
@@ -67,6 +56,7 @@ export function PlainTrackHeader({
67
56
  showTrackLabel,
68
57
  isTrackHidden,
69
58
  isAudioTrack,
59
+ isAudioOnly,
70
60
  onToggleTrackHidden,
71
61
  trailing,
72
62
  }: {
@@ -76,6 +66,7 @@ export function PlainTrackHeader({
76
66
  clipCount: number;
77
67
  isTrackHidden: boolean;
78
68
  isAudioTrack: boolean;
69
+ isAudioOnly: boolean;
79
70
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
80
71
  showTrackLabel: boolean;
81
72
  /** Trailing controls that belong on the control line — the FX entry points,
@@ -111,25 +102,11 @@ export function PlainTrackHeader({
111
102
  truncating name leaves, so the controls sit on the edge whatever the
112
103
  name's length. */}
113
104
  <div className="ml-auto flex shrink-0 items-center gap-1">
114
- {/* Not on an audio track. The control is the old visibility eye, and
115
- on audio it silences rather than hides — but a row that already says
116
- what it is with a speaker does not also need the hide affordance
117
- sitting in the eye's slot. `visible={false}` rather than omitting the
118
- element, so the spacer keeps every row's control columns aligned.
119
-
120
- EXCEPT when the audio track is ALREADY hidden. Withholding the
121
- control unconditionally withheld the only way back: `data-hidden`
122
- silences the clip in preview and drops it from the render, the
123
- panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
124
- else writes it — so a track hidden before this rule (or by "Hide
125
- all", or by hand) was silent with no control anywhere to restore it.
126
- Offering the eye only in that state keeps the affordance off a normal
127
- audio row while leaving the door open from the inside. */}
128
105
  <VisibilityButton
129
106
  hidden={isTrackHidden}
130
107
  trackNumber={trackNumber}
131
108
  trackDisplayNumber={trackDisplayNumber}
132
- visible={!isAudioTrack || isTrackHidden}
109
+ asMute={isAudioOnly}
133
110
  onToggle={onToggleTrackHidden}
134
111
  />
135
112
  {trailing}
@@ -0,0 +1,20 @@
1
+ import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
2
+
3
+ /** The action a hide toggle offers from the current `hidden` state; on audio `data-hidden` is a mute. */
4
+ export function hiddenToggleVerb(asMute: boolean, hidden: boolean): string {
5
+ if (asMute) return hidden ? "Unmute" : "Mute";
6
+ return hidden ? "Show" : "Hide";
7
+ }
8
+
9
+ export function HiddenToggleIcon({
10
+ asMute,
11
+ hidden,
12
+ size,
13
+ }: {
14
+ asMute: boolean;
15
+ hidden: boolean;
16
+ size: number;
17
+ }) {
18
+ const Icon = asMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
19
+ return <Icon size={size} weight="bold" aria-hidden="true" />;
20
+ }
@@ -249,7 +249,7 @@ describe("applySoftReload", () => {
249
249
  // gsap present but MotionPathPlugin unset → async load path.
250
250
  const { iframe, contentWindow } = buildMockIframe({
251
251
  MotionPathPlugin: undefined,
252
- gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
252
+ gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" },
253
253
  });
254
254
  (iframe.contentDocument as unknown as { head: unknown }).head = head;
255
255
 
@@ -260,7 +260,7 @@ describe("applySoftReload", () => {
260
260
  // script has NOT executed yet, so the timeline isn't rebound synchronously.
261
261
  expect(result).toBe("applied");
262
262
  expect(appendedScripts).toHaveLength(1);
263
- expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
263
+ expect(appendedScripts[0]!.src).toContain("gsap@3.14.2/dist/MotionPathPlugin");
264
264
  expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
265
265
 
266
266
  // onerror must NOT run the script (that would reference a missing plugin) —
@@ -384,6 +384,16 @@ describe("ensureMotionPathPluginLoaded", () => {
384
384
  expect(appendedScripts).toHaveLength(0);
385
385
  });
386
386
 
387
+ it("loads the plugin at the composition's own gsap version", () => {
388
+ const { iframe, appendedScripts } = buildBootstrapIframe({
389
+ gsap: { version: "3.14.2", registerPlugin: vi.fn() },
390
+ });
391
+ ensureMotionPathPluginLoaded(iframe);
392
+ expect(appendedScripts[0]!.src).toBe(
393
+ "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
394
+ );
395
+ });
396
+
387
397
  it("appends the plugin script once and registers it on load", () => {
388
398
  const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
389
399
  ensureMotionPathPluginLoaded(iframe);
@@ -481,6 +491,45 @@ describe("applySoftReload authored-style restore", () => {
481
491
  return el.style.getPropertyValue("opacity");
482
492
  }
483
493
 
494
+ it("flushes only elements when a tween targets a plain object (the runtime's duration filler)", () => {
495
+ const el = document.createElement("div");
496
+ // GSAP's clearProps writes target.style.cssText, which throws on a plain object.
497
+ const set = vi.fn((targets: Array<{ style: CSSStyleDeclaration }>) => {
498
+ for (const t of targets) t.style.cssText = "";
499
+ });
500
+ const { iframe } = buildIframeWithTarget(el, {
501
+ gsap: { timeline: vi.fn(), set },
502
+ __timelines: {
503
+ root: {
504
+ kill: vi.fn(),
505
+ getChildren: () => [{ targets: () => [el] }, { targets: () => [{}] }],
506
+ },
507
+ },
508
+ });
509
+
510
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
511
+ expect(set).toHaveBeenCalledWith([el], { clearProps: "all" });
512
+ });
513
+
514
+ it("falls back to a full reload, and says why, when the flush throws", () => {
515
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
516
+ const { iframe } = buildIframeWithTarget(document.createElement("div"), {
517
+ gsap: {
518
+ timeline: vi.fn(),
519
+ set: vi.fn(() => {
520
+ throw new Error("flush failed");
521
+ }),
522
+ },
523
+ });
524
+
525
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
526
+ expect(error).toHaveBeenCalledWith(
527
+ expect.stringContaining("soft reload threw"),
528
+ expect.any(Error),
529
+ );
530
+ error.mockRestore();
531
+ });
532
+
484
533
  it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
485
534
  const el = document.createElement("img");
486
535
  el.setAttribute("data-hf-id", "hf-1");
@@ -1,4 +1,5 @@
1
1
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
2
+ import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
2
3
  import { findAuthoredElement } from "./authoredSource";
3
4
  import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
4
5
  import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
@@ -14,6 +15,7 @@ type IframeWindow = Window & {
14
15
  // re-flash the iframe. Cleared once the plugin loads or errors.
15
16
  __hfMotionPathPluginLoading?: boolean;
16
17
  gsap?: {
18
+ version?: string;
17
19
  timeline?: (...args: unknown[]) => unknown;
18
20
  registerPlugin?: (...plugins: unknown[]) => unknown;
19
21
  set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
@@ -22,14 +24,6 @@ type IframeWindow = Window & {
22
24
  MotionPathPlugin?: unknown;
23
25
  };
24
26
 
25
- /**
26
- * CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
27
- * bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
28
- * version is pinned in a single place.
29
- */
30
- const MOTION_PATH_PLUGIN_CDN =
31
- "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
32
-
33
27
  /**
34
28
  * Pre-load + register MotionPathPlugin ONCE in the preview iframe so
35
29
  * `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
@@ -66,7 +60,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
66
60
  try {
67
61
  win.__hfMotionPathPluginLoading = true;
68
62
  const pluginScript = doc.createElement("script");
69
- pluginScript.src = MOTION_PATH_PLUGIN_CDN;
63
+ pluginScript.src = motionPathPluginUrl(win.gsap?.version);
70
64
  const finalize = () => {
71
65
  win.__hfMotionPathPluginLoading = false;
72
66
  try {
@@ -152,7 +146,8 @@ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
152
146
  if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
153
147
  else reload();
154
148
  return true;
155
- } catch {
149
+ } catch (error) {
150
+ console.error("[Studio] GSAP soft reload threw; falling back to a full reload", error);
156
151
  return false;
157
152
  }
158
153
  }
@@ -369,16 +364,11 @@ export function applySoftReload(
369
364
  // HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
370
365
  const allTargets = [...targets.keys()];
371
366
  if (allTargets.length > 0 && win.gsap?.set) {
372
- const saved: Array<[HTMLElement, string]> = [];
373
- for (const el of allTargets) {
374
- // Iframe-realm node: instanceof HTMLElement fails across realms, and
375
- // gsap targets() only yields elements here — style access is duck-typed.
376
- const styled = el as HTMLElement;
377
- if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
378
- }
379
- try {
380
- win.gsap.set(allTargets, { clearProps: "all" });
381
- } catch {}
367
+ // Iframe-realm nodes: instanceof HTMLElement fails across realms, so style access is duck-typed.
368
+ const saved = allTargets.map(
369
+ (el) => [el as HTMLElement, (el as HTMLElement).style.cssText] as const,
370
+ );
371
+ win.gsap.set(allTargets, { clearProps: "all" });
382
372
  for (const [el, css] of saved) {
383
373
  const s = el.style;
384
374
  s.cssText = css;
@@ -449,7 +439,7 @@ export function applySoftReload(
449
439
  }
450
440
  win.__hfMotionPathPluginLoading = true;
451
441
  const pluginScript = doc.createElement("script");
452
- pluginScript.src = MOTION_PATH_PLUGIN_CDN;
442
+ pluginScript.src = motionPathPluginUrl(win.gsap?.version);
453
443
  pluginScript.onload = () => {
454
444
  win.__hfMotionPathPluginLoading = false;
455
445
  executeScript();
@@ -16,11 +16,9 @@ const CROP = "inset(0px 40px 0px 0px)";
16
16
  vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
17
17
  let browser: Browser;
18
18
  let undoBundle: string;
19
+ let softReloadBundle: string;
19
20
 
20
- beforeAll(async () => {
21
- const executablePath = findSystemChrome();
22
- if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
23
- browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
21
+ async function bundle(file: string, name: string): Promise<string> {
24
22
  const out = await build({
25
23
  configFile: false,
26
24
  logLevel: "silent",
@@ -30,68 +28,85 @@ beforeAll(async () => {
30
28
  build: {
31
29
  write: false,
32
30
  minify: false,
33
- lib: {
34
- entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
35
- formats: ["iife"],
36
- name: "hfUndo",
37
- },
31
+ lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
38
32
  },
39
33
  });
40
- undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
41
- .code;
34
+ return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
35
+ }
36
+
37
+ beforeAll(async () => {
38
+ const executablePath = findSystemChrome();
39
+ if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
40
+ browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
41
+ undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
42
+ softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
42
43
  });
43
44
 
44
45
  afterAll(() => browser?.close());
45
46
 
47
+ type Spec = { gsap: string; placement: string; rotation: number; time: number };
48
+
49
+ /** The bench's root crop fixture, shown at `spec.time` with the module under test loaded. */
50
+ async function openCase(spec: Spec, moduleBundle: string) {
51
+ const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
52
+ writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
53
+ const restored = readFileSync(join(dir, "index.html"), "utf8");
54
+ const page = await browser.newPage();
55
+ await page.setViewport({ width: 1920, height: 1080 });
56
+ await page.setRequestInterception(true);
57
+ // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
58
+ page.on("request", (request) =>
59
+ request.url().endsWith("/gsap.min.js")
60
+ ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
61
+ : request.abort("blockedbyclient"),
62
+ );
63
+ await page.setContent(restored, { waitUntil: "load" });
64
+ await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
65
+ await page.waitForFunction(() => "__player" in window);
66
+ await page.evaluate(moduleBundle);
67
+ await page.evaluate(
68
+ (time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
69
+ spec.time,
70
+ );
71
+ const box = () =>
72
+ page.evaluate(() => {
73
+ const { left, top, width, height } = document
74
+ .getElementById("target")!
75
+ .getBoundingClientRect();
76
+ return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
77
+ });
78
+ return { page, restored, box };
79
+ }
80
+
46
81
  // The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
47
82
  it.each([
48
- { gsap: "hold", placement: "px", rotation: 0 },
49
- { gsap: "tween", placement: "px", rotation: 0 },
50
- { gsap: "tween", placement: "xpercent", rotation: 30 },
83
+ { gsap: "hold", placement: "px", rotation: 0, time: 1 },
84
+ { gsap: "tween", placement: "px", rotation: 0, time: 1 },
85
+ { gsap: "tween", placement: "xpercent", rotation: 30, time: 1 },
86
+ { gsap: "keys", placement: "px", rotation: 0, time: 1 },
87
+ { gsap: "keys", placement: "px", rotation: 30, time: 2 },
88
+ { gsap: "scale", placement: "px", rotation: 0, time: 1 },
89
+ { gsap: "spin", placement: "px", rotation: 30, time: 1 },
90
+ { gsap: "fromto", placement: "px", rotation: 0, time: 1 },
51
91
  ])(
52
- "an undone crop leaves a $gsap $placement r$rotation layer where it was",
92
+ "an undone crop leaves a $gsap $placement r$rotation layer at t$time where it was",
53
93
  async (spec) => {
54
- const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
55
- writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
56
- const restored = readFileSync(join(dir, "index.html"), "utf8");
94
+ const { page, restored, box } = await openCase(spec, undoBundle);
57
95
  const previous = restored.replace(
58
96
  `<div id="target"`,
59
97
  `<div id="target" style="clip-path: ${CROP}"`,
60
98
  );
61
- const page = await browser.newPage();
62
- await page.setViewport({ width: 1920, height: 1080 });
63
- await page.setRequestInterception(true);
64
- // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
65
- page.on("request", (request) =>
66
- request.url().endsWith("/gsap.min.js")
67
- ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
68
- : request.abort("blockedbyclient"),
69
- );
70
- await page.setContent(restored, { waitUntil: "load" });
71
- await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
72
- await page.waitForFunction(() => "__player" in window);
73
- await page.evaluate(undoBundle);
74
-
75
- const box = () =>
76
- page.evaluate(() => {
77
- const { left, top, width, height } = document
78
- .getElementById("target")!
79
- .getBoundingClientRect();
80
- return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
81
- });
82
- await page.evaluate(() =>
83
- (window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
84
- );
85
99
  const before = await box();
86
100
  const outcome = await page.evaluate(
87
- (files, crop) => {
101
+ (files, crop, time) => {
88
102
  document.getElementById("target")!.style.setProperty("clip-path", crop);
89
103
  const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
90
104
  const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
91
- return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
105
+ return undo.applyUndoRestoreToPreview(iframe, "index.html", files, time, () => {});
92
106
  },
93
107
  { "index.html": { previous, restored } },
94
108
  CROP,
109
+ spec.time,
95
110
  );
96
111
 
97
112
  expect(outcome).toBe("soft");
@@ -100,3 +115,35 @@ it.each([
100
115
  },
101
116
  30_000,
102
117
  );
118
+
119
+ // A drop saves the file and soft-reloads it; the re-run must place an animated layer where it was.
120
+ it.each([
121
+ { gsap: "tween", placement: "px", rotation: 0, time: 1 },
122
+ { gsap: "keys", placement: "px", rotation: 30, time: 2 },
123
+ { gsap: "spin", placement: "px", rotation: 0, time: 1 },
124
+ ])(
125
+ "a soft reload keeps a $gsap $placement r$rotation layer at t$time where it was",
126
+ async (spec) => {
127
+ const { page, restored, box } = await openCase(spec, softReloadBundle);
128
+ const before = await box();
129
+ const outcome = await page.evaluate(
130
+ (html, time) => {
131
+ const soft = (window as unknown as { hfSoftReload: typeof import("./gsapSoftReload") })
132
+ .hfSoftReload;
133
+ const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
134
+ const script = soft.extractGsapScriptText(html)!;
135
+ return soft.applySoftReload(iframe, script, {
136
+ currentTimeOverride: time,
137
+ authoredHtml: html,
138
+ });
139
+ },
140
+ restored,
141
+ spec.time,
142
+ );
143
+
144
+ expect(outcome).toBe("applied");
145
+ expect(await box()).toEqual(before);
146
+ await page.close();
147
+ },
148
+ 30_000,
149
+ );
@@ -1,8 +1,9 @@
1
1
  // Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
2
2
  import { parseSavedSource } from "./authoredSource";
3
+ import { elementTargets } from "./elementGsap";
3
4
 
4
5
  type TweenLike = {
5
- targets?: () => Element[];
6
+ targets?: () => unknown[];
6
7
  vars?: Record<string, unknown>;
7
8
  getChildren?: (deep: boolean) => TweenLike[];
8
9
  };
@@ -36,7 +37,7 @@ export function authoringFile(el: Element): string | null {
36
37
 
37
38
  function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
38
39
  const props = tweenedProps(tween.vars);
39
- for (const el of tween.targets?.() ?? []) {
40
+ for (const el of elementTargets(tween)) {
40
41
  const seen = targets.get(el) ?? new Set<string>();
41
42
  for (const prop of props) seen.add(prop);
42
43
  targets.set(el, seen);
@@ -68,7 +69,7 @@ function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
68
69
  for (const tl of timelines) {
69
70
  try {
70
71
  for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
71
- for (const el of tween.targets?.() ?? []) els.add(el);
72
+ for (const el of elementTargets(tween)) els.add(el);
72
73
  }
73
74
  } catch {}
74
75
  }
@@ -9,6 +9,7 @@ export interface RecordEditInput {
9
9
  coalesceKey?: string;
10
10
  coalesceMs?: number;
11
11
  files: Record<string, { before: string; after: string }>;
12
+ created?: string[];
12
13
  }
13
14
 
14
15
  export interface DomEditCommitBaseParams {
@@ -34,6 +34,13 @@ export function isAudioTimelineElement(
34
34
  return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
35
35
  }
36
36
 
37
+ /** A track whose hide toggle reads as mute: the header button and its undo entry. */
38
+ export function isAudioOnlyTrack(
39
+ elements: readonly Pick<TimelineElement, "tag" | "src">[],
40
+ ): boolean {
41
+ return elements.length > 0 && elements.every(isAudioTimelineElement);
42
+ }
43
+
37
44
  /** The two tags the property panel lets you put a volume automation lane on.
38
45
  * Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
39
46
  * `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
@@ -108,11 +115,9 @@ export function resolveBeatSourceTrack(
108
115
  * May this multi-selection be hidden as one action?
109
116
  *
110
117
  * Audio has no visual to hide, and `data-hidden` on an audio element is what
111
- * MUTES it — preview silences it and the render drops it from the mix. The
112
- * timeline withholds the eye on an audio track for that reason
113
- * (`visible={!isAudioTrack}`), and the single-selection panel gates the same
114
- * write on `audioSelection`. The multi-selection "Hide all" was the one path
115
- * left back to it, on a control whose label promises visibility.
118
+ * mutes it: preview silences it and the render drops it from the mix. The timeline
119
+ * and the single-selection panel offer it as a mute; "Hide all" would reach it on a
120
+ * control whose label promises visibility.
116
121
  *
117
122
  * A shared predicate rather than a check in the handler so the panel's button
118
123
  * and the handler's refusal cannot disagree — the button is not the only caller.